caclawphony/elixir/lib/symphony_elixir/http_server.ex
Alex Kotliarskyi b0e0ff0082
Move Elixir observability dashboard to Phoenix (#29)
#### Context

Replace the custom Elixir observability TCP server with Phoenix while
keeping the shipped escript and existing operational API calls working
the same way they did before.

#### TL;DR

*Move the Elixir observability dashboard and API onto Phoenix without
breaking the escript or curl workflows.*

#### Summary

- replace the custom HTTP server with a Phoenix endpoint, router,
controller, and shared presenter
- add a LiveView operations dashboard with PubSub-driven updates and
embed its CSS and JS assets in code
- preserve runtime compatibility by accepting form-style POSTs and
extending the endpoint coverage around those paths

#### Alternatives

- keep extending the hand-rolled TCP server, but that keeps custom
parsing and routing complexity in the app
- serve dashboard assets from priv directories, but the shipped escript
cannot rely on those files existing on disk

#### Test Plan

- [x] `make -C elixir all`
- [x] `mix -C elixir test test/symphony_elixir/extensions_test.exs`
- [x] `./bin/symphony
--i-understand-that-this-will-be-running-without-the-usual-guardrails
--port 40123`
- [x] `curl -si http://127.0.0.1:40123/dashboard.css`
- [x] `curl -si http://127.0.0.1:40123/vendor/phoenix/phoenix.js`
- [x] `curl -si -X POST -d '' http://127.0.0.1:40123/api/v1/refresh`
- [x] `curl -si -X POST -d '' http://127.0.0.1:40123/api/v1/state`

---------

Co-authored-by: Codex <codex@openai.com>
2026-03-04 17:24:56 -08:00

89 lines
2.5 KiB
Elixir

defmodule SymphonyElixir.HttpServer do
@moduledoc """
Compatibility facade that starts the Phoenix observability endpoint when enabled.
"""
alias SymphonyElixir.{Config, Orchestrator}
alias SymphonyElixirWeb.Endpoint
@secret_key_bytes 48
@spec child_spec(keyword()) :: Supervisor.child_spec()
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]}
}
end
@spec start_link(keyword()) :: GenServer.on_start() | :ignore
def start_link(opts \\ []) do
case Keyword.get(opts, :port, Config.server_port()) do
port when is_integer(port) and port >= 0 ->
host = Keyword.get(opts, :host, Config.server_host())
orchestrator = Keyword.get(opts, :orchestrator, Orchestrator)
snapshot_timeout_ms = Keyword.get(opts, :snapshot_timeout_ms, 15_000)
with {:ok, ip} <- parse_host(host) do
endpoint_opts = [
server: true,
http: [ip: ip, port: port],
url: [host: normalize_host(host)],
orchestrator: orchestrator,
snapshot_timeout_ms: snapshot_timeout_ms,
secret_key_base: secret_key_base()
]
endpoint_config =
:symphony_elixir
|> Application.get_env(Endpoint, [])
|> Keyword.merge(endpoint_opts)
Application.put_env(:symphony_elixir, Endpoint, endpoint_config)
Endpoint.start_link()
end
_ ->
:ignore
end
end
@spec bound_port(term()) :: non_neg_integer() | nil
def bound_port(_server \\ __MODULE__) do
case Bandit.PhoenixAdapter.server_info(Endpoint, :http) do
{:ok, {_ip, port}} when is_integer(port) -> port
_ -> nil
end
rescue
_error -> nil
catch
:exit, _reason -> nil
end
defp parse_host({_, _, _, _} = ip), do: {:ok, ip}
defp parse_host({_, _, _, _, _, _, _, _} = ip), do: {:ok, ip}
defp parse_host(host) when is_binary(host) do
charhost = String.to_charlist(host)
case :inet.parse_address(charhost) do
{:ok, ip} ->
{:ok, ip}
{:error, _reason} ->
case :inet.getaddr(charhost, :inet) do
{:ok, ip} -> {:ok, ip}
{:error, _reason} -> :inet.getaddr(charhost, :inet6)
end
end
end
defp normalize_host(host) when host in ["", nil], do: "127.0.0.1"
defp normalize_host(host) when is_binary(host), do: host
defp normalize_host(host), do: to_string(host)
defp secret_key_base do
Base.encode64(:crypto.strong_rand_bytes(@secret_key_bytes), padding: false)
end
end