diff --git a/config/config.exs b/config/config.exs
index 2e2f830..63aeb27 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -19,6 +19,7 @@ config :cortex_status, :services,
dendrite_api_key: "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf",
monitored_sites: [
{"hydrascale.net", "https://hydrascale.net"},
+ {"git.hydrascale.net", "https://git.hydrascale.net"},
{"browser.hydrascale.net", "https://browser.hydrascale.net/health"}
]
@@ -47,4 +48,4 @@ config :logger, :console,
config :phoenix, :json_library, Jason
-import_config "#{config_env()}.exs"
+import_config "#{config_env()}.exs"
\ No newline at end of file
diff --git a/config/runtime.exs b/config/runtime.exs
index 4e63924..4981fd2 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -12,22 +12,22 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret
"""
- host = System.get_env("PHX_HOST") || "status.hydrascale.net"
+ host = System.get_env("PHX_HOST") || "cortex.hydrascale.net"
port = String.to_integer(System.get_env("PORT") || "4000")
config :cortex_status, CortexStatusWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [ip: {127, 0, 0, 1}, port: port],
- secret_key_base: secret_key_base
+ secret_key_base: secret_key_base,
+ check_origin: ["https://cortex.hydrascale.net", "https://status.hydrascale.net"]
- # Override service URLs in prod if env vars are set
config :cortex_status, :services,
symbiont_url: System.get_env("SYMBIONT_URL") || "http://127.0.0.1:8111",
dendrite_url: System.get_env("DENDRITE_URL") || "http://localhost:3000",
dendrite_api_key: System.get_env("DENDRITE_API_KEY") || "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf",
monitored_sites: [
- {"hydrascale.net", "https://hydrascale.net"},
- {"browser.hydrascale.net", "https://browser.hydrascale.net/health"},
- {"cortex.hydrascale.net", "https://cortex.hydrascale.net"}
- ]
-end
+ {"hydrascale.net", "https://hydrascale.net"},
+ {"git.hydrascale.net", "https://git.hydrascale.net"},
+ {"browser.hydrascale.net", "https://browser.hydrascale.net/health"}
+ ]
+end
\ No newline at end of file
diff --git a/elixir_notes.md b/elixir_notes.md
new file mode 100644
index 0000000..64b6303
--- /dev/null
+++ b/elixir_notes.md
@@ -0,0 +1,473 @@
+# Elixir / Phoenix Learnings — Cortex Status Dashboard
+
+Patterns, gotchas, and reference notes from building the cortex_status Phoenix app.
+
+## Project: cortex_status
+
+- **Location on cortex**: `/data/cortex_status/`
+- **Service**: `symbiont-ex-api.service` (systemd)
+- **Port**: 4000 (behind Caddy at status.hydrascale.net)
+- **Framework**: Phoenix 1.7.14, LiveView 1.0.0, LiveDashboard 0.8.4
+
+---
+
+## LiveView Patterns
+
+### PubSub for Real-Time Updates
+The status page subscribes to PubSub topics on mount and receives broadcast updates:
+```elixir
+def mount(_params, _session, socket) do
+ if connected?(socket) do
+ Phoenix.PubSub.subscribe(CortexStatus.PubSub, "service_status")
+ end
+ {:ok, assign(socket, ...)}
+end
+
+def handle_info({:status_update, new_status}, socket) do
+ {:noreply, assign(socket, status: new_status)}
+end
+```
+
+### Polling Pattern (Process.send_after)
+For polling an external API from a LiveView (e.g., task progress):
+```elixir
+@poll_interval 1_000
+
+# Start polling
+timer = Process.send_after(self(), :poll_task, @poll_interval)
+{:noreply, assign(socket, poll_timer: timer)}
+
+# Handle poll
+def handle_info(:poll_task, socket) do
+ case fetch_progress(socket.assigns.task_id) do
+ {:ok, data} when is_map(data) ->
+ terminal = data["status"] in ["completed", "failed"]
+ timer = if terminal, do: nil, else: Process.send_after(self(), :poll_task, @poll_interval)
+ {:noreply, assign(socket, data: data, poll_timer: timer)}
+ _ ->
+ # Don't crash on bad data — keep polling
+ timer = Process.send_after(self(), :poll_task, @poll_interval)
+ {:noreply, assign(socket, poll_timer: timer)}
+ end
+end
+```
+
+**Gotcha**: Always cancel timers on unmount/logout:
+```elixir
+if socket.assigns.poll_timer, do: Process.cancel_timer(socket.assigns.poll_timer)
+```
+
+### Component Attrs and Passing Assigns
+When defining function components with `attr`, use `assigns` directly. For passing whole assigns bundles to sub-components, use a named assign:
+```elixir
+# Don't try to pass @assigns directly — use a named prop
+<.auth_gate socket_assigns={assigns} />
+
+defp auth_gate(assigns) do
+ ~H"""
+ <%= @socket_assigns.prompt %>
+ """
+end
+```
+
+### phx-change Goes on the Form, Not Individual Inputs
+```elixir
+# WRONG: phx-change on textarea alone won't fire
+
+
+# RIGHT: phx-change on the form, inputs trigger it
+
+```
+
+### Enum.with_index Returns {element, index}
+```elixir
+# WRONG — destructuring is backwards:
+for {index, element} <- Enum.with_index(list)
+
+# RIGHT:
+for {element, index} <- Enum.with_index(list)
+```
+
+---
+
+## LiveDashboard Custom Pages
+
+### Basic Structure
+```elixir
+defmodule MyApp.DashboardPages.MyPage do
+ use Phoenix.LiveDashboard.PageBuilder
+
+ @impl true
+ def menu_link(_, _), do: {:ok, "Page Title"}
+
+ @impl true
+ def render_page(_assigns) do
+ {:ok, row(components: [card(value: "Hello", inner_title: "Title")])}
+ end
+end
+```
+
+### Registration in Router
+```elixir
+live_dashboard "/dashboard",
+ metrics: MyAppWeb.Telemetry,
+ additional_pages: [
+ my_page: MyApp.DashboardPages.MyPage
+ ]
+```
+
+### Available Components
+- `card(value:, inner_title:)` — simple KV display
+- `table(columns:, rows:, id:, title:, row_attrs:)` — data table
+- `row(components:)` — horizontal layout
+- `columns(columns:)` — multi-column layout
+
+**Gotcha**: `row_attrs` must be a function: `fn row -> [{"data-id", row.id}] end`
+
+---
+
+## DateTime Gotchas
+
+### DateTime.from_iso8601 Returns {:error, reason}, Not :error
+```elixir
+# WRONG:
+case DateTime.from_iso8601(str) do
+ {:ok, dt, _} -> dt
+ :error -> str # This clause never matches!
+end
+
+# RIGHT:
+case DateTime.from_iso8601(str) do
+ {:ok, dt, _} -> dt
+ {:error, _} -> str # Correct error tuple
+end
+```
+
+---
+
+## HTTP Calls from LiveView/GenServer
+
+### Using Req Library
+```elixir
+# GET
+case Req.get("http://localhost:8111/status", receive_timeout: 5000) do
+ {:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, body}
+ {:ok, %{status: code}} -> {:error, "HTTP #{code}"}
+ {:error, reason} -> {:error, reason}
+end
+
+# POST with JSON
+case Req.post(url, json: %{"prompt" => prompt}) do
+ {:ok, %{status: 200, body: body}} -> {:ok, body}
+ ...
+end
+```
+
+**Gotcha**: Always handle when `body` might be a string (not auto-parsed JSON). Req parses JSON automatically when content-type is application/json.
+
+---
+
+## Application Configuration
+
+### Reading Config at Runtime (Not Compile-Time)
+Use function calls instead of module attributes for config that may change:
+```elixir
+# WRONG — baked in at compile time:
+@symbiont_url Application.get_env(:cortex_status, :services)[:symbiont_url]
+
+# RIGHT — reads at runtime:
+defp symbiont_url do
+ config = Application.get_env(:cortex_status, :services, [])
+ Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111")
+end
+```
+
+---
+
+## os_mon for System Metrics
+
+The app uses Erlang's `os_mon` for host-level metrics (requires `:os_mon` in `extra_applications`):
+```elixir
+cpu_load = :cpu_sup.avg1() / 256 # normalized 0-1
+{mem_total, mem_alloc, _} = :memsup.get_memory_data()
+disk_data = :disksup.get_disk_data() # [{mount, total_kb, percent_used}]
+```
+
+---
+
+## Release & Deployment
+
+### Build Commands
+```bash
+cd /data/cortex_status
+MIX_ENV=prod mix deps.get
+MIX_ENV=prod mix compile
+MIX_ENV=prod mix assets.deploy # tailwind + esbuild
+MIX_ENV=prod mix release --overwrite
+systemctl restart symbiont-ex-api
+```
+
+### Environment Variables (runtime.exs)
+- `SECRET_KEY_BASE` — required in prod
+- `PHX_HOST` — defaults to cortex.hydrascale.net
+- `PORT` — defaults to 4000
+- `SYMBIONT_URL` — override Symbiont API base URL
+
+---
+
+## check_origin: The LiveView Connection Killer
+
+When LiveView connections silently fail (`liveSocket.isConnected()` returns `false`,
+`_mount_attempts` climbs into the thousands), the most likely culprit is a `check_origin`
+mismatch. Phoenix checks the HTTP `Origin` header against its configured URL.
+
+**Symptom**: Phoenix logs show:
+```
+[error] Could not check origin for Phoenix.Socket transport.
+Origin of the request: https://cortex.hydrascale.net
+```
+
+**Root cause**: Setting `url: [host: h, port: 443, scheme: "https"]` in `runtime.exs`
+causes Phoenix to expect an origin of `https://cortex.hydrascale.net:443`, but browsers
+send `https://cortex.hydrascale.net` (no port — 443 is implicit for HTTPS). String
+comparison fails.
+
+**Fix**: Explicitly set `check_origin` in the endpoint config in `runtime.exs`:
+```elixir
+config :cortex_status, CortexStatusWeb.Endpoint,
+ url: [host: host, port: 443, scheme: "https"],
+ http: [ip: {127, 0, 0, 1}, port: port],
+ secret_key_base: secret_key_base,
+ check_origin: ["https://cortex.hydrascale.net"] # ← explicit, no port
+```
+
+**Note**: LiveView uses `longpoll` as its transport (WebSocket upgrades may not work
+through all Caddy configs). `longpoll` is functionally identical for most purposes —
+slightly more latency but fully supported.
+
+---
+
+## LiveView Auth Gate Pattern
+
+### Password-Protected LiveView Pages
+For pages that need a simple password gate (like Mission Control), use assigns to track
+auth state and conditionally render:
+```elixir
+def mount(_params, _session, socket) do
+ {:ok, assign(socket, authenticated: false, error: nil, prompt: "")}
+end
+
+def handle_event("authenticate", %{"password" => pw}, socket) do
+ if pw == Application.get_env(:my_app, :task_password) do
+ {:noreply, assign(socket, authenticated: true, error: nil)}
+ else
+ {:noreply, assign(socket, error: "Invalid password")}
+ end
+end
+```
+
+In the template, wrap content with `<%= if @authenticated do %>`.
+
+**Gotcha**: The password check happens server-side in the LiveView process, so it's
+secure even though the HTML is rendered client-side. But remember: the initial static
+render (before WebSocket connects) will show the unauthenticated state, so don't put
+sensitive data in the assigns until after authentication.
+
+---
+
+## LiveView Silent Failures — Always Show Error Feedback
+
+### The Problem
+When a LiveView event handler hits an error (API call fails, validation error, etc.)
+and you just return `{:noreply, socket}` without updating assigns, the user sees
+*nothing happen*. The button appears to do nothing. This is extremely confusing.
+
+### The Fix Pattern
+Always maintain an `error` assign and display it:
+```elixir
+def handle_event("submit_task", %{"prompt" => prompt}, socket) do
+ case submit_to_api(prompt) do
+ {:ok, task_id} ->
+ {:noreply, assign(socket, task_id: task_id, error: nil)}
+ {:error, reason} ->
+ {:noreply, assign(socket, error: "Task failed: #{reason}")}
+ end
+end
+```
+
+In the template:
+```elixir
+<%= if @error do %>
+
<%= @error %>
+<% end %>
+```
+
+**Lesson learned the hard way**: The Mission Control "Execute" button did nothing
+for a while because the Symbiont API was rejecting the auth token, but the error
+was swallowed silently. Always surface errors to the UI.
+
+---
+
+## API Authentication from LiveView
+
+### Bearer Token vs Query Param vs JSON Body
+When calling external APIs from LiveView, be careful about where auth tokens go.
+FastAPI's `Depends()` for auth reads from specific locations — if the API expects
+a query param and you send it in the JSON body, auth silently fails.
+
+**Preferred pattern**: Use `Authorization: Bearer ` header — it's unambiguous:
+```elixir
+headers = [{"authorization", "Bearer #{token}"}, {"content-type", "application/json"}]
+case Req.post(url, json: payload, headers: headers) do
+ {:ok, %{status: 200, body: body}} -> {:ok, body}
+ {:ok, %{status: code, body: body}} -> {:error, "HTTP #{code}: #{inspect(body)}"}
+ {:error, reason} -> {:error, inspect(reason)}
+end
+```
+
+**Gotcha**: Always match on the status code, not just `{:ok, _}`. A 401 or 500
+response is still `{:ok, %Req.Response{}}` — it's only `:error` if the HTTP
+request itself fails (timeout, DNS, connection refused).
+
+---
+
+## LiveView Form Gotchas (Expanded)
+
+### phx-submit Doesn't Fire Without a Submit Button
+If your form has `phx-submit="do_thing"` but no `