From b6e5e4dbb01ecca312dc889c5cf2cf69bdd2b9bf Mon Sep 17 00:00:00 2001 From: Alex Kotliarskyi Date: Wed, 4 Mar 2026 17:00:07 -0800 Subject: [PATCH 1/2] feat(elixir): move observability dashboard to Phoenix Summary: - replace the custom TCP observability server with a Phoenix and Bandit endpoint while preserving the existing JSON API routes - add a LiveView operations dashboard backed by a shared presenter and Phoenix PubSub updates - serve dashboard styling as a static CSS asset and document the new web stack and build flow Rationale: - keep the optional observability surface maintainable without carrying custom HTTP parsing and routing code - preserve the repo's no-node workflow while giving the dashboard a real Phoenix runtime and cleaner asset boundaries Tests: - make -C elixir all Co-authored-by: Codex --- elixir/.gitignore | 3 + elixir/Makefile | 8 +- elixir/README.md | 15 +- elixir/config/config.exs | 16 + elixir/lib/symphony_elixir.ex | 1 + elixir/lib/symphony_elixir/http_server.ex | 519 +---------- .../lib/symphony_elixir/status_dashboard.ex | 3 + .../symphony_elixir_web/components/layouts.ex | 56 ++ .../observability_api_controller.ex | 63 ++ elixir/lib/symphony_elixir_web/endpoint.ex | 60 ++ elixir/lib/symphony_elixir_web/error_html.ex | 8 + elixir/lib/symphony_elixir_web/error_json.ex | 8 + .../live/dashboard_live.ex | 330 +++++++ .../observability_pubsub.ex | 25 + elixir/lib/symphony_elixir_web/presenter.ex | 181 ++++ elixir/lib/symphony_elixir_web/router.ex | 34 + elixir/mix.exs | 19 +- elixir/mix.lock | 16 + elixir/priv/static/dashboard.css | 463 ++++++++++ elixir/test/support/test_support.exs | 23 +- .../test/symphony_elixir/extensions_test.exs | 815 ++++++++---------- .../observability_pubsub_test.exs | 28 + 22 files changed, 1735 insertions(+), 959 deletions(-) create mode 100644 elixir/config/config.exs create mode 100644 elixir/lib/symphony_elixir_web/components/layouts.ex create mode 100644 elixir/lib/symphony_elixir_web/controllers/observability_api_controller.ex create mode 100644 elixir/lib/symphony_elixir_web/endpoint.ex create mode 100644 elixir/lib/symphony_elixir_web/error_html.ex create mode 100644 elixir/lib/symphony_elixir_web/error_json.ex create mode 100644 elixir/lib/symphony_elixir_web/live/dashboard_live.ex create mode 100644 elixir/lib/symphony_elixir_web/observability_pubsub.ex create mode 100644 elixir/lib/symphony_elixir_web/presenter.ex create mode 100644 elixir/lib/symphony_elixir_web/router.ex create mode 100644 elixir/priv/static/dashboard.css create mode 100644 elixir/test/symphony_elixir/observability_pubsub_test.exs diff --git a/elixir/.gitignore b/elixir/.gitignore index 1009b148..a2514390 100644 --- a/elixir/.gitignore +++ b/elixir/.gitignore @@ -13,6 +13,9 @@ # Temporary files, for example, from tests. /tmp/ +# Generated browser assets. +/priv/static/assets/ + # Local runtime logs. /log/ /logs/ diff --git a/elixir/Makefile b/elixir/Makefile index 5c285dac..9c1ae909 100644 --- a/elixir/Makefile +++ b/elixir/Makefile @@ -1,4 +1,4 @@ -.PHONY: help all setup deps fmt fmt-check lint test coverage ci dialyzer +.PHONY: help all setup deps build fmt fmt-check lint test coverage ci dialyzer MIX ?= mix @@ -11,6 +11,9 @@ setup: deps: $(MIX) deps.get +build: + $(MIX) build + fmt: $(MIX) format @@ -31,7 +34,8 @@ dialyzer: $(MIX) dialyzer --format short ci: - $(MAKE) deps + $(MAKE) setup + $(MAKE) build $(MAKE) fmt-check $(MAKE) lint $(MAKE) coverage diff --git a/elixir/README.md b/elixir/README.md index 7c2e02c6..3f711587 100644 --- a/elixir/README.md +++ b/elixir/README.md @@ -78,7 +78,7 @@ If no path is passed, Symphony defaults to `./WORKFLOW.md`. Optional flags: - `--logs-root` tells Symphony to write logs under a different directory (default: `./log`) -- `--port` also starts the HTTP observability service (default: disabled) +- `--port` also starts the Phoenix observability service (default: disabled) The `WORKFLOW.md` file uses YAML front matter for configuration, plus a Markdown body used as the Codex session prompt. @@ -145,8 +145,17 @@ codex: ``` - If `WORKFLOW.md` is missing or has invalid YAML, startup and scheduling are halted until fixed. -- `server.port` or CLI `--port` enables the optional HTTP dashboard and JSON API at `/`, - `/api/v1/state`, `/api/v1/`, and `/api/v1/refresh`. +- `server.port` or CLI `--port` enables the optional Phoenix LiveView dashboard and JSON API at + `/`, `/api/v1/state`, `/api/v1/`, and `/api/v1/refresh`. + +## Web dashboard + +The observability UI now runs on a minimal Phoenix stack: + +- LiveView for the dashboard at `/` +- JSON API for operational debugging under `/api/v1/*` +- Bandit as the HTTP server +- Phoenix dependency static assets for the LiveView client bootstrap ## Project Layout diff --git a/elixir/config/config.exs b/elixir/config/config.exs new file mode 100644 index 00000000..11744f66 --- /dev/null +++ b/elixir/config/config.exs @@ -0,0 +1,16 @@ +import Config + +config :phoenix, :json_library, Jason + +config :symphony_elixir, SymphonyElixirWeb.Endpoint, + adapter: Bandit.PhoenixAdapter, + url: [host: "localhost"], + render_errors: [ + formats: [html: SymphonyElixirWeb.ErrorHTML, json: SymphonyElixirWeb.ErrorJSON], + layout: false + ], + pubsub_server: SymphonyElixir.PubSub, + live_view: [signing_salt: "symphony-live-view"], + secret_key_base: String.duplicate("s", 64), + check_origin: false, + server: false diff --git a/elixir/lib/symphony_elixir.ex b/elixir/lib/symphony_elixir.ex index 44ef0bb9..18561af8 100644 --- a/elixir/lib/symphony_elixir.ex +++ b/elixir/lib/symphony_elixir.ex @@ -24,6 +24,7 @@ defmodule SymphonyElixir.Application do :ok = SymphonyElixir.LogFile.configure() children = [ + {Phoenix.PubSub, name: SymphonyElixir.PubSub}, {Task.Supervisor, name: SymphonyElixir.TaskSupervisor}, SymphonyElixir.WorkflowStore, SymphonyElixir.Orchestrator, diff --git a/elixir/lib/symphony_elixir/http_server.ex b/elixir/lib/symphony_elixir/http_server.ex index 585132ae..47686e93 100644 --- a/elixir/lib/symphony_elixir/http_server.ex +++ b/elixir/lib/symphony_elixir/http_server.ex @@ -1,29 +1,17 @@ defmodule SymphonyElixir.HttpServer do @moduledoc """ - Lightweight HTTP server for the optional observability endpoints. + Compatibility facade that starts the Phoenix observability endpoint when enabled. """ - use GenServer - alias SymphonyElixir.{Config, Orchestrator} + alias SymphonyElixirWeb.Endpoint - @accept_timeout_ms 100 - @recv_timeout_ms 1_000 - @max_header_bytes 8_192 - @max_body_bytes 1_048_576 - - defmodule State do - @moduledoc false - - defstruct [:listen_socket, :port, :orchestrator, :snapshot_timeout_ms] - end + @secret_key_bytes 48 @spec child_spec(keyword()) :: Supervisor.child_spec() def child_spec(opts) do - name = Keyword.get(opts, :name, __MODULE__) - %{ - id: name, + id: __MODULE__, start: {__MODULE__, :start_link, [opts]} } end @@ -32,414 +20,44 @@ defmodule SymphonyElixir.HttpServer do def start_link(opts \\ []) do case Keyword.get(opts, :port, Config.server_port()) do port when is_integer(port) and port >= 0 -> - name = Keyword.get(opts, :name, __MODULE__) - GenServer.start_link(__MODULE__, Keyword.put(opts, :port, port), name: name) - - _ -> - :ignore - end - end - - @spec bound_port(GenServer.name()) :: non_neg_integer() | nil - def bound_port(server \\ __MODULE__) do - case Process.whereis(server) do - pid when is_pid(pid) -> - GenServer.call(server, :bound_port) - - _ -> - nil - end - end - - @impl true - def init(opts) do - host = Keyword.get(opts, :host, Config.server_host()) - port = Keyword.fetch!(opts, :port) - orchestrator = Keyword.get(opts, :orchestrator, Orchestrator) - snapshot_timeout_ms = Keyword.get(opts, :snapshot_timeout_ms, 15_000) - - with {:ok, ip} <- parse_host(host), - {:ok, listen_socket} <- - :gen_tcp.listen(port, [:binary, {:ip, ip}, {:packet, :raw}, {:active, false}, {:reuseaddr, true}]), - {:ok, actual_port} <- :inet.port(listen_socket) do - send(self(), :accept) - - {:ok, - %State{ - listen_socket: listen_socket, - port: actual_port, - orchestrator: orchestrator, - snapshot_timeout_ms: snapshot_timeout_ms - }} - else - {:error, reason} -> - {:stop, reason} - end - end - - @impl true - def handle_call(:bound_port, _from, %State{port: port} = state) do - {:reply, port, state} - end - - @impl true - def handle_info( - :accept, - %State{ - listen_socket: listen_socket, - orchestrator: orchestrator, - snapshot_timeout_ms: snapshot_timeout_ms - } = state - ) do - case :gen_tcp.accept(listen_socket, @accept_timeout_ms) do - {:ok, socket} -> - {:ok, _pid} = - Task.Supervisor.start_child(SymphonyElixir.TaskSupervisor, fn -> - serve_connection(socket, orchestrator, snapshot_timeout_ms) - end) - - send(self(), :accept) - {:noreply, state} - - {:error, :timeout} -> - send(self(), :accept) - {:noreply, state} - - {:error, :closed} -> - {:stop, :normal, state} - - {:error, reason} -> - {:stop, reason, state} - end - end - - @impl true - def terminate(_reason, %State{listen_socket: listen_socket}) when is_port(listen_socket) do - :gen_tcp.close(listen_socket) - :ok - end - - def terminate(_reason, _state), do: :ok - - @spec parse_raw_request_for_test(String.t()) :: - {:ok, String.t(), map(), String.t()} | {:error, :bad_request} - def parse_raw_request_for_test(data) when is_binary(data), do: parse_raw_request(data) - - @spec parse_host_for_test(String.t() | :inet.ip_address()) :: {:ok, :inet.ip_address()} | {:error, term()} - def parse_host_for_test(host), do: parse_host(host) - - defp serve_connection(socket, orchestrator, snapshot_timeout_ms) do - case read_request(socket) do - {:ok, request} -> - :ok = :gen_tcp.send(socket, route(request, orchestrator, snapshot_timeout_ms)) - - {:error, reason} -> - case request_error_response(reason) do - nil -> :ok - response -> :ok = :gen_tcp.send(socket, response) - end - end - after - :gen_tcp.close(socket) - end - - defp read_request(socket) do - with {:ok, data} <- recv_until_headers(socket, ""), - {:ok, request_line, headers, remainder} <- parse_raw_request(data), - {:ok, method, path} <- parse_request_line(request_line), - {:ok, body} <- read_body(socket, headers, remainder) do - {:ok, %{method: method, path: path, headers: headers, body: body}} - end - end - - defp recv_until_headers(socket, acc) do - cond do - byte_size(acc) > @max_header_bytes -> - {:error, :headers_too_large} - - String.contains?(acc, "\r\n\r\n") -> - {:ok, acc} - - true -> - case :gen_tcp.recv(socket, 0, @recv_timeout_ms) do - {:ok, chunk} -> recv_until_headers(socket, acc <> chunk) - {:error, reason} -> {:error, reason} - end - end - end - - defp parse_raw_request(data) do - case String.split(data, "\r\n\r\n", parts: 2) do - [head, remainder] -> - case String.split(head, "\r\n", trim: true) do - [request_line | header_lines] -> - {:ok, request_line, parse_headers(header_lines), remainder} - - _ -> - {:error, :bad_request} + 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 _ -> - {:error, :bad_request} - end - end - - defp parse_headers(header_lines) do - Enum.reduce(header_lines, %{}, fn line, headers -> - case String.split(line, ":", parts: 2) do - [name, value] -> - Map.put(headers, String.downcase(String.trim(name)), String.trim(value)) - - _ -> - headers - end - end) - end - - defp parse_request_line(line) do - case String.split(line, " ", parts: 3) do - [method, path, _version] -> {:ok, method, path} - _ -> {:error, :bad_request} - end - end - - defp read_body(socket, headers, remainder) do - content_length = - headers - |> Map.get("content-length", "0") - |> Integer.parse() - |> case do - {length, _} when length >= 0 -> length - _ -> 0 - end - - cond do - content_length > @max_body_bytes -> - {:error, :body_too_large} - - byte_size(remainder) >= content_length -> - {:ok, binary_part(remainder, 0, content_length)} - - true -> - case :gen_tcp.recv(socket, content_length - byte_size(remainder), @recv_timeout_ms) do - {:ok, tail} -> {:ok, remainder <> tail} - {:error, reason} -> {:error, reason} - end - end - end - - defp route(%{method: "GET", path: "/"} = request, orchestrator, snapshot_timeout_ms), - do: html_response(200, render_dashboard(request, orchestrator, snapshot_timeout_ms)) - - defp route(%{method: "GET", path: "/api/v1/state"}, orchestrator, snapshot_timeout_ms), - do: json_response(200, state_payload(orchestrator, snapshot_timeout_ms)) - - defp route(%{method: "POST", path: "/api/v1/refresh"}, orchestrator, _snapshot_timeout_ms) do - case Orchestrator.request_refresh(orchestrator) do - :unavailable -> - error_response(503, "orchestrator_unavailable", "Orchestrator is unavailable") - - payload -> - json_response(202, Map.update!(payload, :requested_at, &DateTime.to_iso8601/1)) - end - end - - defp route(%{path: "/api/v1/state"}, _orchestrator, _snapshot_timeout_ms), - do: error_response(405, "method_not_allowed", "Method not allowed") - - defp route(%{path: "/api/v1/refresh"}, _orchestrator, _snapshot_timeout_ms), - do: error_response(405, "method_not_allowed", "Method not allowed") - - defp route(%{method: "GET", path: "/api/v1/" <> issue_identifier}, orchestrator, snapshot_timeout_ms) do - case issue_payload(issue_identifier, orchestrator, snapshot_timeout_ms) do - {:ok, payload} -> json_response(200, payload) - {:error, :issue_not_found} -> error_response(404, "issue_not_found", "Issue not found") - end - end - - defp route(%{path: "/"}, _orchestrator, _snapshot_timeout_ms), - do: error_response(405, "method_not_allowed", "Method not allowed") - - defp route(%{path: "/api/v1/" <> _issue_identifier}, _orchestrator, _snapshot_timeout_ms), - do: error_response(405, "method_not_allowed", "Method not allowed") - - defp route(_request, _orchestrator, _snapshot_timeout_ms), - do: error_response(404, "not_found", "Route not found") - - defp state_payload(orchestrator, snapshot_timeout_ms) do - generated_at = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() - - case Orchestrator.snapshot(orchestrator, snapshot_timeout_ms) do - %{} = snapshot -> - %{ - generated_at: generated_at, - counts: %{ - running: length(snapshot.running), - retrying: length(snapshot.retrying) - }, - running: Enum.map(snapshot.running, &running_entry_payload/1), - retrying: Enum.map(snapshot.retrying, &retry_entry_payload/1), - codex_totals: snapshot.codex_totals, - rate_limits: snapshot.rate_limits - } - - :timeout -> - %{generated_at: generated_at, error: %{code: "snapshot_timeout", message: "Snapshot timed out"}} - - :unavailable -> - %{generated_at: generated_at, error: %{code: "snapshot_unavailable", message: "Snapshot unavailable"}} + :ignore end end - defp issue_payload(issue_identifier, orchestrator, snapshot_timeout_ms) do - case Orchestrator.snapshot(orchestrator, snapshot_timeout_ms) do - %{} = snapshot -> - running = Enum.find(snapshot.running, &(&1.identifier == issue_identifier)) - retry = Enum.find(snapshot.retrying, &(&1.identifier == issue_identifier)) - - if is_nil(running) and is_nil(retry) do - {:error, :issue_not_found} - else - {:ok, issue_payload_body(issue_identifier, running, retry)} - end - - _ -> - {:error, :issue_not_found} + @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 - end - - defp issue_payload_body(issue_identifier, running, retry) do - %{ - issue_identifier: issue_identifier, - issue_id: issue_id_from_entries(running, retry), - status: issue_status(running, retry), - workspace: %{ - path: Path.join(Config.workspace_root(), issue_identifier) - }, - attempts: %{ - restart_count: restart_count(retry), - current_retry_attempt: retry_attempt(retry) - }, - running: running && running_issue_payload(running), - retry: retry && retry_issue_payload(retry), - logs: %{ - codex_session_logs: [] - }, - recent_events: (running && recent_events_payload(running)) || [], - last_error: retry && retry.error, - tracked: %{} - } - end - - defp issue_id_from_entries(running, retry), - do: (running && running.issue_id) || (retry && retry.issue_id) - - defp restart_count(retry), do: max(retry_attempt(retry) - 1, 0) - defp retry_attempt(nil), do: 0 - defp retry_attempt(retry), do: retry.attempt || 0 - - defp issue_status(_running, nil), do: "running" - defp issue_status(nil, _retry), do: "retrying" - defp issue_status(_running, _retry), do: "running" - - defp running_entry_payload(entry) do - %{ - issue_id: entry.issue_id, - issue_identifier: entry.identifier, - state: entry.state, - session_id: entry.session_id, - turn_count: Map.get(entry, :turn_count, 0), - last_event: entry.last_codex_event, - last_message: summarize_message(entry.last_codex_message), - started_at: iso8601(entry.started_at), - last_event_at: iso8601(entry.last_codex_timestamp), - tokens: %{ - input_tokens: entry.codex_input_tokens, - output_tokens: entry.codex_output_tokens, - total_tokens: entry.codex_total_tokens - } - } - end - - defp retry_entry_payload(entry) do - %{ - issue_id: entry.issue_id, - issue_identifier: entry.identifier, - attempt: entry.attempt, - due_at: due_at_iso8601(entry.due_in_ms), - error: entry.error - } - end - - defp running_issue_payload(running) do - %{ - session_id: running.session_id, - turn_count: Map.get(running, :turn_count, 0), - state: running.state, - started_at: iso8601(running.started_at), - last_event: running.last_codex_event, - last_message: summarize_message(running.last_codex_message), - last_event_at: iso8601(running.last_codex_timestamp), - tokens: %{ - input_tokens: running.codex_input_tokens, - output_tokens: running.codex_output_tokens, - total_tokens: running.codex_total_tokens - } - } - end - - defp retry_issue_payload(retry) do - %{ - attempt: retry.attempt, - due_at: due_at_iso8601(retry.due_in_ms), - error: retry.error - } - end - - defp recent_events_payload(running) do - [ - %{ - at: iso8601(running.last_codex_timestamp), - event: running.last_codex_event, - message: summarize_message(running.last_codex_message) - } - ] - |> Enum.reject(&is_nil(&1.at)) - end - - defp render_dashboard(_request, orchestrator, snapshot_timeout_ms) do - payload = state_payload(orchestrator, snapshot_timeout_ms) - title = "Symphony Dashboard" - body = Jason.encode!(payload, pretty: true) - escaped_body = escape_html(body) - - """ - - - - - #{title} - - - -

#{title}

-
#{escaped_body}
- - - """ - end - - defp escape_html(value) when is_binary(value) do - value - |> String.replace("&", "&") - |> String.replace("<", "<") - |> String.replace(">", ">") - |> String.replace("\"", """) - |> String.replace("'", "'") + rescue + _error -> nil + catch + :exit, _reason -> nil end defp parse_host({_, _, _, _} = ip), do: {:ok, ip} @@ -460,68 +78,11 @@ defmodule SymphonyElixir.HttpServer do end end - defp json_response(status, payload) do - body = Jason.encode!(payload) - build_response(status, "application/json; charset=utf-8", body) - 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 html_response(status, body) do - build_response(status, "text/html; charset=utf-8", body) + defp secret_key_base do + Base.encode64(:crypto.strong_rand_bytes(@secret_key_bytes), padding: false) end - - defp error_response(status, code, message) do - json_response(status, %{error: %{code: code, message: message}}) - end - - defp build_response(status, content_type, body) do - [ - "HTTP/1.1 #{status} #{reason_phrase(status)}\r\n", - "content-type: #{content_type}\r\n", - "content-length: #{byte_size(body)}\r\n", - "connection: close\r\n", - "\r\n", - body - ] - |> IO.iodata_to_binary() - end - - defp reason_phrase(200), do: "OK" - defp reason_phrase(202), do: "Accepted" - defp reason_phrase(400), do: "Bad Request" - defp reason_phrase(413), do: "Payload Too Large" - defp reason_phrase(404), do: "Not Found" - defp reason_phrase(405), do: "Method Not Allowed" - defp reason_phrase(503), do: "Service Unavailable" - - defp request_error_response(:closed), do: nil - - defp request_error_response(:headers_too_large), - do: error_response(413, "headers_too_large", "Request headers exceed the maximum size") - - defp request_error_response(:body_too_large), - do: error_response(413, "body_too_large", "Request body exceeds the maximum size") - - defp request_error_response(_reason), - do: error_response(400, "bad_request", "Malformed HTTP request") - - defp summarize_message(%{message: message}) when is_binary(message), do: message - defp summarize_message(message) when is_binary(message), do: message - defp summarize_message(_message), do: nil - - defp due_at_iso8601(due_in_ms) when is_integer(due_in_ms) do - DateTime.utc_now() - |> DateTime.add(div(due_in_ms, 1_000), :second) - |> DateTime.truncate(:second) - |> DateTime.to_iso8601() - end - - defp due_at_iso8601(_due_in_ms), do: nil - - defp iso8601(%DateTime{} = datetime) do - datetime - |> DateTime.truncate(:second) - |> DateTime.to_iso8601() - end - - defp iso8601(_datetime), do: nil end diff --git a/elixir/lib/symphony_elixir/status_dashboard.ex b/elixir/lib/symphony_elixir/status_dashboard.ex index 9e5c4350..19b628bf 100644 --- a/elixir/lib/symphony_elixir/status_dashboard.ex +++ b/elixir/lib/symphony_elixir/status_dashboard.ex @@ -8,6 +8,7 @@ defmodule SymphonyElixir.StatusDashboard do alias SymphonyElixir.{Config, HttpServer} alias SymphonyElixir.Orchestrator + alias SymphonyElixirWeb.ObservabilityPubSub @minimum_idle_rerender_ms 1_000 @throughput_window_ms 5_000 @@ -81,6 +82,8 @@ defmodule SymphonyElixir.StatusDashboard do @spec notify_update(GenServer.name()) :: :ok def notify_update(server \\ __MODULE__) do + ObservabilityPubSub.broadcast_update() + case GenServer.whereis(server) do pid when is_pid(pid) -> send(pid, :refresh) diff --git a/elixir/lib/symphony_elixir_web/components/layouts.ex b/elixir/lib/symphony_elixir_web/components/layouts.ex new file mode 100644 index 00000000..afac13e3 --- /dev/null +++ b/elixir/lib/symphony_elixir_web/components/layouts.ex @@ -0,0 +1,56 @@ +defmodule SymphonyElixirWeb.Layouts do + @moduledoc """ + Shared layouts for the observability dashboard. + """ + + use Phoenix.Component + + @spec root(map()) :: Phoenix.LiveView.Rendered.t() + def root(assigns) do + assigns = assign(assigns, :csrf_token, Plug.CSRFProtection.get_csrf_token()) + + ~H""" + + + + + + + Symphony Observability + + + + + + + + {@inner_content} + + + """ + end + + @spec app(map()) :: Phoenix.LiveView.Rendered.t() + def app(assigns) do + ~H""" +
+ {@inner_content} +
+ """ + end +end diff --git a/elixir/lib/symphony_elixir_web/controllers/observability_api_controller.ex b/elixir/lib/symphony_elixir_web/controllers/observability_api_controller.ex new file mode 100644 index 00000000..da764f12 --- /dev/null +++ b/elixir/lib/symphony_elixir_web/controllers/observability_api_controller.ex @@ -0,0 +1,63 @@ +defmodule SymphonyElixirWeb.ObservabilityApiController do + @moduledoc """ + JSON API for Symphony observability data. + """ + + use Phoenix.Controller, formats: [:json] + + alias Plug.Conn + alias SymphonyElixirWeb.{Endpoint, Presenter} + + @spec state(Conn.t(), map()) :: Conn.t() + def state(conn, _params) do + json(conn, Presenter.state_payload(orchestrator(), snapshot_timeout_ms())) + end + + @spec issue(Conn.t(), map()) :: Conn.t() + def issue(conn, %{"issue_identifier" => issue_identifier}) do + case Presenter.issue_payload(issue_identifier, orchestrator(), snapshot_timeout_ms()) do + {:ok, payload} -> + json(conn, payload) + + {:error, :issue_not_found} -> + error_response(conn, 404, "issue_not_found", "Issue not found") + end + end + + @spec refresh(Conn.t(), map()) :: Conn.t() + def refresh(conn, _params) do + case Presenter.refresh_payload(orchestrator()) do + {:ok, payload} -> + conn + |> put_status(202) + |> json(payload) + + {:error, :unavailable} -> + error_response(conn, 503, "orchestrator_unavailable", "Orchestrator is unavailable") + end + end + + @spec method_not_allowed(Conn.t(), map()) :: Conn.t() + def method_not_allowed(conn, _params) do + error_response(conn, 405, "method_not_allowed", "Method not allowed") + end + + @spec not_found(Conn.t(), map()) :: Conn.t() + def not_found(conn, _params) do + error_response(conn, 404, "not_found", "Route not found") + end + + defp error_response(conn, status, code, message) do + conn + |> put_status(status) + |> json(%{error: %{code: code, message: message}}) + end + + defp orchestrator do + Endpoint.config(:orchestrator) || SymphonyElixir.Orchestrator + end + + defp snapshot_timeout_ms do + Endpoint.config(:snapshot_timeout_ms) || 15_000 + end +end diff --git a/elixir/lib/symphony_elixir_web/endpoint.ex b/elixir/lib/symphony_elixir_web/endpoint.ex new file mode 100644 index 00000000..45e4dd3e --- /dev/null +++ b/elixir/lib/symphony_elixir_web/endpoint.ex @@ -0,0 +1,60 @@ +defmodule SymphonyElixirWeb.Endpoint do + @moduledoc """ + Phoenix endpoint for Symphony's optional observability UI and API. + """ + + use Phoenix.Endpoint, otp_app: :symphony_elixir + + @session_options [ + store: :cookie, + key: "_symphony_elixir_key", + signing_salt: "symphony-session" + ] + + socket("/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: false + ) + + plug(Plug.Static, + at: "/", + from: {:symphony_elixir, "priv/static"}, + gzip: false, + only: ~w(assets dashboard.css) + ) + + plug(Plug.Static, + at: "/vendor/phoenix_html", + from: {:phoenix_html, "priv/static"}, + gzip: false, + only: ~w(phoenix_html.js) + ) + + plug(Plug.Static, + at: "/vendor/phoenix", + from: {:phoenix, "priv/static"}, + gzip: false, + only: ~w(phoenix.js) + ) + + plug(Plug.Static, + at: "/vendor/phoenix_live_view", + from: {:phoenix_live_view, "priv/static"}, + gzip: false, + only: ~w(phoenix_live_view.js) + ) + + plug(Plug.RequestId) + plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint]) + + plug(Plug.Parsers, + parsers: [:json], + pass: ["application/json"], + json_decoder: Jason + ) + + plug(Plug.MethodOverride) + plug(Plug.Head) + plug(Plug.Session, @session_options) + plug(SymphonyElixirWeb.Router) +end diff --git a/elixir/lib/symphony_elixir_web/error_html.ex b/elixir/lib/symphony_elixir_web/error_html.ex new file mode 100644 index 00000000..5b2722a2 --- /dev/null +++ b/elixir/lib/symphony_elixir_web/error_html.ex @@ -0,0 +1,8 @@ +defmodule SymphonyElixirWeb.ErrorHTML do + @moduledoc false + + @spec render(String.t(), map()) :: String.t() + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/elixir/lib/symphony_elixir_web/error_json.ex b/elixir/lib/symphony_elixir_web/error_json.ex new file mode 100644 index 00000000..5babea4c --- /dev/null +++ b/elixir/lib/symphony_elixir_web/error_json.ex @@ -0,0 +1,8 @@ +defmodule SymphonyElixirWeb.ErrorJSON do + @moduledoc false + + @spec render(String.t(), map()) :: map() + def render(template, _assigns) do + %{error: %{code: "request_failed", message: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/elixir/lib/symphony_elixir_web/live/dashboard_live.ex b/elixir/lib/symphony_elixir_web/live/dashboard_live.ex new file mode 100644 index 00000000..a30631c1 --- /dev/null +++ b/elixir/lib/symphony_elixir_web/live/dashboard_live.ex @@ -0,0 +1,330 @@ +defmodule SymphonyElixirWeb.DashboardLive do + @moduledoc """ + Live observability dashboard for Symphony. + """ + + use Phoenix.LiveView, layout: {SymphonyElixirWeb.Layouts, :app} + + alias SymphonyElixirWeb.{Endpoint, ObservabilityPubSub, Presenter} + @runtime_tick_ms 1_000 + + @impl true + def mount(_params, _session, socket) do + socket = + socket + |> assign(:payload, load_payload()) + |> assign(:now, DateTime.utc_now()) + + if connected?(socket) do + :ok = ObservabilityPubSub.subscribe() + schedule_runtime_tick() + end + + {:ok, socket} + end + + @impl true + def handle_info(:runtime_tick, socket) do + schedule_runtime_tick() + {:noreply, assign(socket, :now, DateTime.utc_now())} + end + + @impl true + def handle_info(:observability_updated, socket) do + {:noreply, + socket + |> assign(:payload, load_payload()) + |> assign(:now, DateTime.utc_now())} + end + + @impl true + def render(assigns) do + ~H""" +
+
+
+
+

+ Symphony Observability +

+

+ Operations Dashboard +

+

+ Current state, retry pressure, token usage, and orchestration health for the active Symphony runtime. +

+
+ +
+ + + Live + + + + Offline + +
+
+
+ + <%= if @payload[:error] do %> +
+

+ Snapshot unavailable +

+

+ <%= @payload.error.code %>: <%= @payload.error.message %> +

+
+ <% else %> +
+
+

Running

+

<%= @payload.counts.running %>

+

Active issue sessions in the current runtime.

+
+ +
+

Retrying

+

<%= @payload.counts.retrying %>

+

Issues waiting for the next retry window.

+
+ +
+

Total tokens

+

<%= format_int(@payload.codex_totals.total_tokens) %>

+

+ In <%= format_int(@payload.codex_totals.input_tokens) %> / Out <%= format_int(@payload.codex_totals.output_tokens) %> +

+
+ +
+

Runtime

+

<%= format_runtime_seconds(total_runtime_seconds(@payload, @now)) %>

+

Total Codex runtime across completed and active sessions.

+
+
+ +
+
+
+

Rate limits

+

Latest upstream rate-limit snapshot, when available.

+
+
+ +
<%= pretty_value(@payload.rate_limits) %>
+
+ +
+
+
+

Running sessions

+

Active issues, last known agent activity, and token usage.

+
+
+ + <%= if @payload.running == [] do %> +

No active sessions.

+ <% else %> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IssueStateSessionRuntime / turnsCodex updateTokens
+
+ <%= entry.issue_identifier %> + JSON details +
+
+ + <%= entry.state %> + + +
+ <%= if entry.session_id do %> + + <% else %> + n/a + <% end %> +
+
<%= format_runtime_and_turns(entry.started_at, entry.turn_count, @now) %> +
+ <%= entry.last_message || to_string(entry.last_event || "n/a") %> + + <%= entry.last_event || "n/a" %> + <%= if entry.last_event_at do %> + ยท <%= entry.last_event_at %> + <% end %> + +
+
+
+ Total: <%= format_int(entry.tokens.total_tokens) %> + In <%= format_int(entry.tokens.input_tokens) %> / Out <%= format_int(entry.tokens.output_tokens) %> +
+
+
+ <% end %> +
+ +
+
+
+

Retry queue

+

Issues waiting for the next retry window.

+
+
+ + <%= if @payload.retrying == [] do %> +

No issues are currently backing off.

+ <% else %> +
+ + + + + + + + + + + + + + + + + +
IssueAttemptDue atError
+
+ <%= entry.issue_identifier %> + JSON details +
+
<%= entry.attempt %><%= entry.due_at || "n/a" %><%= entry.error || "n/a" %>
+
+ <% end %> +
+ <% end %> +
+ """ + end + + defp load_payload do + Presenter.state_payload(orchestrator(), snapshot_timeout_ms()) + end + + defp orchestrator do + Endpoint.config(:orchestrator) || SymphonyElixir.Orchestrator + end + + defp snapshot_timeout_ms do + Endpoint.config(:snapshot_timeout_ms) || 15_000 + end + + defp completed_runtime_seconds(payload) do + payload.codex_totals.seconds_running || 0 + end + + defp total_runtime_seconds(payload, now) do + completed_runtime_seconds(payload) + + Enum.reduce(payload.running, 0, fn entry, total -> + total + runtime_seconds_from_started_at(entry.started_at, now) + end) + end + + defp format_runtime_and_turns(started_at, turn_count, now) when is_integer(turn_count) and turn_count > 0 do + "#{format_runtime_seconds(runtime_seconds_from_started_at(started_at, now))} / #{turn_count}" + end + + defp format_runtime_and_turns(started_at, _turn_count, now), + do: format_runtime_seconds(runtime_seconds_from_started_at(started_at, now)) + + defp format_runtime_seconds(seconds) when is_number(seconds) do + whole_seconds = max(trunc(seconds), 0) + mins = div(whole_seconds, 60) + secs = rem(whole_seconds, 60) + "#{mins}m #{secs}s" + end + + defp runtime_seconds_from_started_at(%DateTime{} = started_at, %DateTime{} = now) do + DateTime.diff(now, started_at, :second) + end + + defp runtime_seconds_from_started_at(started_at, %DateTime{} = now) when is_binary(started_at) do + case DateTime.from_iso8601(started_at) do + {:ok, parsed, _offset} -> runtime_seconds_from_started_at(parsed, now) + _ -> 0 + end + end + + defp runtime_seconds_from_started_at(_started_at, _now), do: 0 + + defp format_int(value) when is_integer(value) do + value + |> Integer.to_string() + |> String.reverse() + |> String.replace(~r/.{3}(?=.)/, "\\0,") + |> String.reverse() + end + + defp format_int(_value), do: "n/a" + + defp state_badge_class(state) do + base = "state-badge" + normalized = state |> to_string() |> String.downcase() + + cond do + String.contains?(normalized, ["progress", "running", "active"]) -> "#{base} state-badge-active" + String.contains?(normalized, ["blocked", "error", "failed"]) -> "#{base} state-badge-danger" + String.contains?(normalized, ["todo", "queued", "pending", "retry"]) -> "#{base} state-badge-warning" + true -> base + end + end + + defp schedule_runtime_tick do + Process.send_after(self(), :runtime_tick, @runtime_tick_ms) + end + + defp pretty_value(nil), do: "n/a" + defp pretty_value(value), do: inspect(value, pretty: true, limit: :infinity) +end diff --git a/elixir/lib/symphony_elixir_web/observability_pubsub.ex b/elixir/lib/symphony_elixir_web/observability_pubsub.ex new file mode 100644 index 00000000..a3fead26 --- /dev/null +++ b/elixir/lib/symphony_elixir_web/observability_pubsub.ex @@ -0,0 +1,25 @@ +defmodule SymphonyElixirWeb.ObservabilityPubSub do + @moduledoc """ + PubSub helpers for observability dashboard updates. + """ + + @pubsub SymphonyElixir.PubSub + @topic "observability:dashboard" + @update_message :observability_updated + + @spec subscribe() :: :ok | {:error, term()} + def subscribe do + Phoenix.PubSub.subscribe(@pubsub, @topic) + end + + @spec broadcast_update() :: :ok + def broadcast_update do + case Process.whereis(@pubsub) do + pid when is_pid(pid) -> + Phoenix.PubSub.broadcast(@pubsub, @topic, @update_message) + + _ -> + :ok + end + end +end diff --git a/elixir/lib/symphony_elixir_web/presenter.ex b/elixir/lib/symphony_elixir_web/presenter.ex new file mode 100644 index 00000000..34eb1e66 --- /dev/null +++ b/elixir/lib/symphony_elixir_web/presenter.ex @@ -0,0 +1,181 @@ +defmodule SymphonyElixirWeb.Presenter do + @moduledoc """ + Shared projections for the observability API and dashboard. + """ + + alias SymphonyElixir.{Config, Orchestrator, StatusDashboard} + + @spec state_payload(GenServer.name(), timeout()) :: map() + def state_payload(orchestrator, snapshot_timeout_ms) do + generated_at = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + + case Orchestrator.snapshot(orchestrator, snapshot_timeout_ms) do + %{} = snapshot -> + %{ + generated_at: generated_at, + counts: %{ + running: length(snapshot.running), + retrying: length(snapshot.retrying) + }, + running: Enum.map(snapshot.running, &running_entry_payload/1), + retrying: Enum.map(snapshot.retrying, &retry_entry_payload/1), + codex_totals: snapshot.codex_totals, + rate_limits: snapshot.rate_limits + } + + :timeout -> + %{generated_at: generated_at, error: %{code: "snapshot_timeout", message: "Snapshot timed out"}} + + :unavailable -> + %{generated_at: generated_at, error: %{code: "snapshot_unavailable", message: "Snapshot unavailable"}} + end + end + + @spec issue_payload(String.t(), GenServer.name(), timeout()) :: {:ok, map()} | {:error, :issue_not_found} + def issue_payload(issue_identifier, orchestrator, snapshot_timeout_ms) when is_binary(issue_identifier) do + case Orchestrator.snapshot(orchestrator, snapshot_timeout_ms) do + %{} = snapshot -> + running = Enum.find(snapshot.running, &(&1.identifier == issue_identifier)) + retry = Enum.find(snapshot.retrying, &(&1.identifier == issue_identifier)) + + if is_nil(running) and is_nil(retry) do + {:error, :issue_not_found} + else + {:ok, issue_payload_body(issue_identifier, running, retry)} + end + + _ -> + {:error, :issue_not_found} + end + end + + @spec refresh_payload(GenServer.name()) :: {:ok, map()} | {:error, :unavailable} + def refresh_payload(orchestrator) do + case Orchestrator.request_refresh(orchestrator) do + :unavailable -> + {:error, :unavailable} + + payload -> + {:ok, Map.update!(payload, :requested_at, &DateTime.to_iso8601/1)} + end + end + + defp issue_payload_body(issue_identifier, running, retry) do + %{ + issue_identifier: issue_identifier, + issue_id: issue_id_from_entries(running, retry), + status: issue_status(running, retry), + workspace: %{ + path: Path.join(Config.workspace_root(), issue_identifier) + }, + attempts: %{ + restart_count: restart_count(retry), + current_retry_attempt: retry_attempt(retry) + }, + running: running && running_issue_payload(running), + retry: retry && retry_issue_payload(retry), + logs: %{ + codex_session_logs: [] + }, + recent_events: (running && recent_events_payload(running)) || [], + last_error: retry && retry.error, + tracked: %{} + } + end + + defp issue_id_from_entries(running, retry), + do: (running && running.issue_id) || (retry && retry.issue_id) + + defp restart_count(retry), do: max(retry_attempt(retry) - 1, 0) + defp retry_attempt(nil), do: 0 + defp retry_attempt(retry), do: retry.attempt || 0 + + defp issue_status(_running, nil), do: "running" + defp issue_status(nil, _retry), do: "retrying" + defp issue_status(_running, _retry), do: "running" + + defp running_entry_payload(entry) do + %{ + issue_id: entry.issue_id, + issue_identifier: entry.identifier, + state: entry.state, + session_id: entry.session_id, + turn_count: Map.get(entry, :turn_count, 0), + last_event: entry.last_codex_event, + last_message: summarize_message(entry.last_codex_message), + started_at: iso8601(entry.started_at), + last_event_at: iso8601(entry.last_codex_timestamp), + tokens: %{ + input_tokens: entry.codex_input_tokens, + output_tokens: entry.codex_output_tokens, + total_tokens: entry.codex_total_tokens + } + } + end + + defp retry_entry_payload(entry) do + %{ + issue_id: entry.issue_id, + issue_identifier: entry.identifier, + attempt: entry.attempt, + due_at: due_at_iso8601(entry.due_in_ms), + error: entry.error + } + end + + defp running_issue_payload(running) do + %{ + session_id: running.session_id, + turn_count: Map.get(running, :turn_count, 0), + state: running.state, + started_at: iso8601(running.started_at), + last_event: running.last_codex_event, + last_message: summarize_message(running.last_codex_message), + last_event_at: iso8601(running.last_codex_timestamp), + tokens: %{ + input_tokens: running.codex_input_tokens, + output_tokens: running.codex_output_tokens, + total_tokens: running.codex_total_tokens + } + } + end + + defp retry_issue_payload(retry) do + %{ + attempt: retry.attempt, + due_at: due_at_iso8601(retry.due_in_ms), + error: retry.error + } + end + + defp recent_events_payload(running) do + [ + %{ + at: iso8601(running.last_codex_timestamp), + event: running.last_codex_event, + message: summarize_message(running.last_codex_message) + } + ] + |> Enum.reject(&is_nil(&1.at)) + end + + defp summarize_message(nil), do: nil + defp summarize_message(message), do: StatusDashboard.humanize_codex_message(message) + + defp due_at_iso8601(due_in_ms) when is_integer(due_in_ms) do + DateTime.utc_now() + |> DateTime.add(div(due_in_ms, 1_000), :second) + |> DateTime.truncate(:second) + |> DateTime.to_iso8601() + end + + defp due_at_iso8601(_due_in_ms), do: nil + + defp iso8601(%DateTime{} = datetime) do + datetime + |> DateTime.truncate(:second) + |> DateTime.to_iso8601() + end + + defp iso8601(_datetime), do: nil +end diff --git a/elixir/lib/symphony_elixir_web/router.ex b/elixir/lib/symphony_elixir_web/router.ex new file mode 100644 index 00000000..5f35cdb8 --- /dev/null +++ b/elixir/lib/symphony_elixir_web/router.ex @@ -0,0 +1,34 @@ +defmodule SymphonyElixirWeb.Router do + @moduledoc """ + Router for Symphony's observability dashboard and API. + """ + + use Phoenix.Router + import Phoenix.LiveView.Router + + pipeline :browser do + plug(:fetch_session) + plug(:fetch_live_flash) + plug(:put_root_layout, html: {SymphonyElixirWeb.Layouts, :root}) + plug(:protect_from_forgery) + plug(:put_secure_browser_headers) + end + + scope "/", SymphonyElixirWeb do + pipe_through(:browser) + + live("/", DashboardLive, :index) + end + + scope "/", SymphonyElixirWeb do + get("/api/v1/state", ObservabilityApiController, :state) + + match(:*, "/", ObservabilityApiController, :method_not_allowed) + match(:*, "/api/v1/state", ObservabilityApiController, :method_not_allowed) + post("/api/v1/refresh", ObservabilityApiController, :refresh) + match(:*, "/api/v1/refresh", ObservabilityApiController, :method_not_allowed) + get("/api/v1/:issue_identifier", ObservabilityApiController, :issue) + match(:*, "/api/v1/:issue_identifier", ObservabilityApiController, :method_not_allowed) + match(:*, "/*path", ObservabilityApiController, :not_found) + end +end diff --git a/elixir/mix.exs b/elixir/mix.exs index 43cf8b03..19860300 100644 --- a/elixir/mix.exs +++ b/elixir/mix.exs @@ -6,6 +6,7 @@ defmodule SymphonyElixir.MixProject do app: :symphony_elixir, version: "0.1.0", elixir: "~> 1.19", + compilers: [:phoenix_live_view] ++ Mix.compilers(), start_permanent: Mix.env() == :prod, test_coverage: [ summary: [ @@ -21,9 +22,19 @@ defmodule SymphonyElixir.MixProject do SymphonyElixir.CLI, SymphonyElixir.Codex.AppServer, SymphonyElixir.Codex.DynamicTool, + SymphonyElixir.HttpServer, SymphonyElixir.StatusDashboard, SymphonyElixir.LogFile, - SymphonyElixir.Workspace + SymphonyElixir.Workspace, + SymphonyElixirWeb.DashboardLive, + SymphonyElixirWeb.Endpoint, + SymphonyElixirWeb.ErrorHTML, + SymphonyElixirWeb.ErrorJSON, + SymphonyElixirWeb.Layouts, + SymphonyElixirWeb.ObservabilityApiController, + SymphonyElixirWeb.Presenter, + SymphonyElixirWeb.Router, + SymphonyElixirWeb.Router.Helpers ] ], test_ignore_filters: [ @@ -50,6 +61,12 @@ defmodule SymphonyElixir.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:bandit, "~> 1.8"}, + {:floki, ">= 0.30.0", only: :test}, + {:lazy_html, ">= 0.1.0", only: :test}, + {:phoenix, "~> 1.8.0"}, + {:phoenix_html, "~> 4.2"}, + {:phoenix_live_view, "~> 1.1.0"}, {:req, "~> 0.5"}, {:jason, "~> 1.4"}, {:yaml_elixir, "~> 2.12"}, diff --git a/elixir/mix.lock b/elixir/mix.lock index 015baaf9..4f52fd70 100644 --- a/elixir/mix.lock +++ b/elixir/mix.lock @@ -1,22 +1,38 @@ %{ + "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, "date_time_parser": {:hex, :date_time_parser, "1.3.0", "6ba16850b5ab83dd126576451023ab65349e29af2336ca5084aa1e37025b476e", [:mix], [{:kday, "~> 1.0", [hex: :kday, repo: "hexpm", optional: false]}], "hexpm", "93c8203a8ddc66b1f1531fc0e046329bf0b250c75ffa09567ef03d2c09218e8c"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, + "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "kday": {:hex, :kday, "1.1.0", "64efac85279a12283eaaf3ad6f13001ca2dff943eda8c53288179775a8c057a0", [:mix], [{:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}], "hexpm", "69703055d63b8d5b260479266c78b0b3e66f7aecdd2022906cd9bf09892a266d"}, + "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "phoenix": {:hex, :phoenix, "1.8.4", "0387f84f00071cba8d71d930b9121b2fb3645197a9206c31b908d2e7902a4851", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c988b1cd3b084eebb13e6676d572597d387fa607dab258526637b4e6c4c08543"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.25", "abc1bdf7f148d7f9a003f149834cc858b24290c433b10ef6d1cbb1d6e9a211ca", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b8946e474799da1f874eab7e9ce107502c96ca318ed46d19f811f847df270865"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "solid": {:hex, :solid, "1.2.2", "615d3fb75e12b575d99976ca49f242b1e603f98489d30bf8634b5ab47d85e33f", [:mix], [{:date_time_parser, "~> 1.2", [hex: :date_time_parser, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "410d0af6c0cdfd9d58ed2d22158f4fb0733a49f7b59b8e3bdb26f05919ae38ae"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, } diff --git a/elixir/priv/static/dashboard.css b/elixir/priv/static/dashboard.css new file mode 100644 index 00000000..bc191c0c --- /dev/null +++ b/elixir/priv/static/dashboard.css @@ -0,0 +1,463 @@ +:root { + color-scheme: light; + --page: #f7f7f8; + --page-soft: #fbfbfc; + --page-deep: #ececf1; + --card: rgba(255, 255, 255, 0.94); + --card-muted: #f3f4f6; + --ink: #202123; + --muted: #6e6e80; + --line: #ececf1; + --line-strong: #d9d9e3; + --accent: #10a37f; + --accent-ink: #0f513f; + --accent-soft: #e8faf4; + --danger: #b42318; + --danger-soft: #fef3f2; + --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.05); + --shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.08); +} + +* { + box-sizing: border-box; +} + +html { + background: var(--page); +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top, rgba(16, 163, 127, 0.12) 0%, rgba(16, 163, 127, 0) 30%), + linear-gradient(180deg, var(--page-soft) 0%, var(--page) 24%, #f3f4f6 100%); + color: var(--ink); + font-family: "Sohne", "SF Pro Text", "Helvetica Neue", "Segoe UI", sans-serif; + line-height: 1.5; +} + +a { + color: var(--ink); + text-decoration: none; + transition: color 140ms ease; +} + +a:hover { + color: var(--accent); +} + +button { + appearance: none; + border: 1px solid var(--accent); + background: var(--accent); + color: white; + border-radius: 999px; + padding: 0.72rem 1.08rem; + cursor: pointer; + font: inherit; + font-weight: 600; + letter-spacing: -0.01em; + box-shadow: 0 8px 20px rgba(16, 163, 127, 0.18); + transition: + transform 140ms ease, + box-shadow 140ms ease, + background 140ms ease, + border-color 140ms ease; +} + +button:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(16, 163, 127, 0.22); +} + +button.secondary { + background: var(--card); + color: var(--ink); + border-color: var(--line-strong); + box-shadow: var(--shadow-sm); +} + +button.secondary:hover { + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.08); +} + +.subtle-button { + appearance: none; + border: 1px solid var(--line-strong); + background: rgba(255, 255, 255, 0.72); + color: var(--muted); + border-radius: 999px; + padding: 0.34rem 0.72rem; + cursor: pointer; + font: inherit; + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.01em; + box-shadow: none; + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease; +} + +.subtle-button:hover { + transform: none; + box-shadow: none; + background: white; + border-color: var(--muted); + color: var(--ink); +} + +pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +code, +pre, +.mono { + font-family: "Sohne Mono", "SFMono-Regular", "SF Mono", Consolas, "Liberation Mono", monospace; +} + +.mono, +.numeric { + font-variant-numeric: tabular-nums slashed-zero; + font-feature-settings: "tnum" 1, "zero" 1; +} + +.app-shell { + max-width: 1280px; + margin: 0 auto; + padding: 2rem 1rem 3.5rem; +} + +.dashboard-shell { + display: grid; + gap: 1rem; +} + +.hero-card, +.section-card, +.metric-card, +.error-card { + background: var(--card); + border: 1px solid rgba(217, 217, 227, 0.82); + box-shadow: var(--shadow-sm); + backdrop-filter: blur(18px); +} + +.hero-card { + border-radius: 28px; + padding: clamp(1.25rem, 3vw, 2rem); + box-shadow: var(--shadow-lg); +} + +.hero-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1.25rem; + align-items: start; +} + +.eyebrow { + margin: 0; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.76rem; + font-weight: 600; +} + +.hero-title { + margin: 0.35rem 0 0; + font-size: clamp(2rem, 4vw, 3.3rem); + line-height: 0.98; + letter-spacing: -0.04em; +} + +.hero-copy { + margin: 0.75rem 0 0; + max-width: 46rem; + color: var(--muted); + font-size: 1rem; +} + +.status-stack { + display: grid; + justify-items: end; + align-content: start; + min-width: min(100%, 9rem); +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.45rem; + min-height: 2rem; + padding: 0.35rem 0.78rem; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--card-muted); + color: var(--muted); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +.status-badge-dot { + width: 0.52rem; + height: 0.52rem; + border-radius: 999px; + background: currentColor; + opacity: 0.9; +} + +.status-badge-live { + display: none; + background: var(--accent-soft); + border-color: rgba(16, 163, 127, 0.18); + color: var(--accent-ink); +} + +.status-badge-offline { + background: #f5f5f7; + border-color: var(--line-strong); + color: var(--muted); +} + +[data-phx-main].phx-connected .status-badge-live { + display: inline-flex; +} + +[data-phx-main].phx-connected .status-badge-offline { + display: none; +} + +.metric-grid { + display: grid; + gap: 0.85rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.metric-card { + border-radius: 22px; + padding: 1rem 1.05rem 1.1rem; +} + +.metric-label { + margin: 0; + color: var(--muted); + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.metric-value { + margin: 0.35rem 0 0; + font-size: clamp(1.6rem, 2vw, 2.1rem); + line-height: 1.05; + letter-spacing: -0.03em; +} + +.metric-detail { + margin: 0.45rem 0 0; + color: var(--muted); + font-size: 0.88rem; +} + +.section-card { + border-radius: 24px; + padding: 1.15rem; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + flex-wrap: wrap; +} + +.section-title { + margin: 0; + font-size: 1.08rem; + line-height: 1.2; + letter-spacing: -0.02em; +} + +.section-copy { + margin: 0.35rem 0 0; + color: var(--muted); + font-size: 0.94rem; +} + +.table-wrap { + overflow-x: auto; + margin-top: 1rem; +} + +.data-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; +} + +.data-table-running { + table-layout: fixed; + min-width: 980px; +} + +.data-table th { + padding: 0 0.5rem 0.75rem 0; + text-align: left; + color: var(--muted); + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.data-table td { + padding: 0.9rem 0.5rem 0.9rem 0; + border-top: 1px solid var(--line); + vertical-align: top; + font-size: 0.94rem; +} + +.issue-stack, +.session-stack, +.detail-stack, +.token-stack { + display: grid; + gap: 0.24rem; + min-width: 0; +} + +.event-text { + font-weight: 500; + line-height: 1.45; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.event-meta { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.state-badge { + display: inline-flex; + align-items: center; + min-height: 1.85rem; + padding: 0.3rem 0.68rem; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--card-muted); + color: var(--ink); + font-size: 0.8rem; + font-weight: 600; + line-height: 1; +} + +.state-badge-active { + background: var(--accent-soft); + border-color: rgba(16, 163, 127, 0.18); + color: var(--accent-ink); +} + +.state-badge-warning { + background: #fff7e8; + border-color: #f1d8a6; + color: #8a5a00; +} + +.state-badge-danger { + background: var(--danger-soft); + border-color: #f6d3cf; + color: var(--danger); +} + +.issue-id { + font-weight: 600; + letter-spacing: -0.01em; +} + +.issue-link { + color: var(--muted); + font-size: 0.86rem; +} + +.muted { + color: var(--muted); +} + +.code-panel { + margin-top: 1rem; + padding: 1rem; + border-radius: 18px; + background: #f5f5f7; + border: 1px solid var(--line); + color: #353740; + font-size: 0.9rem; +} + +.empty-state { + margin: 1rem 0 0; + color: var(--muted); +} + +.error-card { + border-radius: 24px; + padding: 1.25rem; + background: linear-gradient(180deg, #fff8f7 0%, var(--danger-soft) 100%); + border-color: #f6d3cf; +} + +.error-title { + margin: 0; + color: var(--danger); + font-size: 1.15rem; + letter-spacing: -0.02em; +} + +.error-copy { + margin: 0.45rem 0 0; + color: var(--danger); +} + +@media (max-width: 860px) { + .app-shell { + padding: 1rem 0.85rem 2rem; + } + + .hero-grid { + grid-template-columns: 1fr; + } + + .status-stack { + justify-items: start; + } + + .metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + .metric-grid { + grid-template-columns: 1fr; + } + + .section-card, + .hero-card, + .error-card { + border-radius: 20px; + padding: 1rem; + } +} diff --git a/elixir/test/support/test_support.exs b/elixir/test/support/test_support.exs index ca35d862..bea30f2c 100644 --- a/elixir/test/support/test_support.exs +++ b/elixir/test/support/test_support.exs @@ -39,7 +39,7 @@ defmodule SymphonyElixir.TestSupport do stop_default_http_server() on_exit(fn -> - Workflow.clear_workflow_file_path() + Application.delete_env(:symphony_elixir, :workflow_file_path) Application.delete_env(:symphony_elixir, :server_port_override) Application.delete_env(:symphony_elixir, :memory_tracker_issues) Application.delete_env(:symphony_elixir, :memory_tracker_recipient) @@ -56,7 +56,11 @@ defmodule SymphonyElixir.TestSupport do File.write!(path, workflow) if Process.whereis(SymphonyElixir.WorkflowStore) do - SymphonyElixir.WorkflowStore.force_reload() + try do + SymphonyElixir.WorkflowStore.force_reload() + catch + :exit, _reason -> :ok + end end :ok @@ -66,10 +70,17 @@ defmodule SymphonyElixir.TestSupport do def restore_env(key, value), do: System.put_env(key, value) def stop_default_http_server do - case Process.whereis(SymphonyElixir.HttpServer) do - pid when is_pid(pid) -> - Supervisor.terminate_child(SymphonyElixir.Supervisor, SymphonyElixir.HttpServer) - Process.exit(pid, :normal) + case Enum.find(Supervisor.which_children(SymphonyElixir.Supervisor), fn + {SymphonyElixir.HttpServer, _pid, _type, _modules} -> true + _child -> false + end) do + {SymphonyElixir.HttpServer, pid, _type, _modules} when is_pid(pid) -> + :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, SymphonyElixir.HttpServer) + + if Process.alive?(pid) do + Process.exit(pid, :normal) + end + :ok _ -> diff --git a/elixir/test/symphony_elixir/extensions_test.exs b/elixir/test/symphony_elixir/extensions_test.exs index 08efc891..fc7c8c08 100644 --- a/elixir/test/symphony_elixir/extensions_test.exs +++ b/elixir/test/symphony_elixir/extensions_test.exs @@ -1,10 +1,14 @@ defmodule SymphonyElixir.ExtensionsTest do use SymphonyElixir.TestSupport - alias SymphonyElixir.HttpServer.State, as: HttpServerState + import Phoenix.ConnTest + import Phoenix.LiveViewTest + alias SymphonyElixir.Linear.Adapter alias SymphonyElixir.Tracker.Memory + @endpoint SymphonyElixirWeb.Endpoint + defmodule FakeLinearClient do def fetch_candidate_issues do send(self(), :fetch_candidate_issues_called) @@ -87,6 +91,16 @@ defmodule SymphonyElixir.ExtensionsTest do :ok end + setup do + endpoint_config = Application.get_env(:symphony_elixir, SymphonyElixirWeb.Endpoint, []) + + on_exit(fn -> + Application.put_env(:symphony_elixir, SymphonyElixirWeb.Endpoint, endpoint_config) + end) + + :ok + end + test "workflow store reloads changes, keeps last good workflow, and falls back when stopped" do ensure_workflow_store_running() assert {:ok, %{prompt: "You are an agent for this repository."}} = Workflow.current() @@ -129,7 +143,9 @@ defmodule SymphonyElixir.ExtensionsTest do assert :ok = Supervisor.terminate_child(SymphonyElixir.Supervisor, WorkflowStore) Workflow.set_workflow_file_path(missing_path) - assert {:error, {:missing_workflow_file, ^missing_path, :enoent}} = WorkflowStore.force_reload() + + assert {:error, {:missing_workflow_file, ^missing_path, :enoent}} = + WorkflowStore.force_reload() write_workflow_file!(manual_path, prompt: "Manual workflow prompt") Workflow.set_workflow_file_path(manual_path) @@ -157,7 +173,10 @@ defmodule SymphonyElixir.ExtensionsTest do Process.exit(manual_pid, :normal) restart_result = Supervisor.restart_child(SymphonyElixir.Supervisor, WorkflowStore) - assert match?({:ok, _pid}, restart_result) or match?({:error, {:already_started, _pid}}, restart_result) + + assert match?({:ok, _pid}, restart_result) or + match?({:error, {:already_started, _pid}}, restart_result) + Workflow.set_workflow_file_path(existing_path) WorkflowStore.force_reload() end @@ -228,7 +247,12 @@ defmodule SymphonyElixir.ExtensionsTest do Process.put( {FakeLinearClient, :graphql_results}, [ - {:ok, %{"data" => %{"issue" => %{"team" => %{"states" => %{"nodes" => [%{"id" => "state-1"}]}}}}}}, + {:ok, + %{ + "data" => %{ + "issue" => %{"team" => %{"states" => %{"nodes" => [%{"id" => "state-1"}]}}} + } + }}, {:ok, %{"data" => %{"issueUpdate" => %{"success" => true}}}} ] ) @@ -236,13 +260,20 @@ defmodule SymphonyElixir.ExtensionsTest do assert :ok = Adapter.update_issue_state("issue-1", "Done") assert_receive {:graphql_called, state_lookup_query, %{issueId: "issue-1", stateName: "Done"}} assert state_lookup_query =~ "states" + assert_receive {:graphql_called, update_issue_query, %{issueId: "issue-1", stateId: "state-1"}} + assert update_issue_query =~ "issueUpdate" Process.put( {FakeLinearClient, :graphql_results}, [ - {:ok, %{"data" => %{"issue" => %{"team" => %{"states" => %{"nodes" => [%{"id" => "state-1"}]}}}}}}, + {:ok, + %{ + "data" => %{ + "issue" => %{"team" => %{"states" => %{"nodes" => [%{"id" => "state-1"}]}}} + } + }}, {:ok, %{"data" => %{"issueUpdate" => %{"success" => false}}}} ] ) @@ -260,7 +291,12 @@ defmodule SymphonyElixir.ExtensionsTest do Process.put( {FakeLinearClient, :graphql_results}, [ - {:ok, %{"data" => %{"issue" => %{"team" => %{"states" => %{"nodes" => [%{"id" => "state-1"}]}}}}}}, + {:ok, + %{ + "data" => %{ + "issue" => %{"team" => %{"states" => %{"nodes" => [%{"id" => "state-1"}]}}} + } + }}, {:ok, %{"data" => %{}}} ] ) @@ -270,7 +306,12 @@ defmodule SymphonyElixir.ExtensionsTest do Process.put( {FakeLinearClient, :graphql_results}, [ - {:ok, %{"data" => %{"issue" => %{"team" => %{"states" => %{"nodes" => [%{"id" => "state-1"}]}}}}}}, + {:ok, + %{ + "data" => %{ + "issue" => %{"team" => %{"states" => %{"nodes" => [%{"id" => "state-1"}]}}} + } + }}, :unexpected ] ) @@ -278,533 +319,371 @@ defmodule SymphonyElixir.ExtensionsTest do assert {:error, :issue_update_failed} = Adapter.update_issue_state("issue-1", "Odd") end - test "http server serves html and json endpoints end-to-end" do - write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") - orchestrator_name = Module.concat(__MODULE__, :HttpOrchestrator) - server_name = Module.concat(__MODULE__, :HttpServer) - {:ok, orchestrator_pid} = Orchestrator.start_link(name: orchestrator_name) - - {:ok, server_pid} = - HttpServer.start_link( - name: server_name, - host: "127.0.0.1", - port: 0, - orchestrator: orchestrator_name, - snapshot_timeout_ms: 1_000 - ) - - on_exit(fn -> - if Process.alive?(server_pid), do: Process.exit(server_pid, :normal) - if Process.alive?(orchestrator_pid), do: Process.exit(orchestrator_pid, :normal) - end) - - running_entry = %{ - pid: self(), - ref: make_ref(), - identifier: "MT-HTTP", - issue: %Issue{id: "issue-http", identifier: "MT-HTTP", state: "In Progress"}, - session_id: "thread-http", - turn_count: 7, - codex_app_server_pid: nil, - last_codex_message: "rendered", - last_codex_timestamp: nil, - last_codex_event: :notification, - codex_input_tokens: 4, - codex_output_tokens: 8, - codex_total_tokens: 12, - started_at: DateTime.utc_now() - } - - :sys.replace_state(orchestrator_pid, fn state -> - %{ - state - | running: %{"issue-http" => running_entry}, - retry_attempts: %{ - "issue-retry" => %{ - attempt: 2, - due_at_ms: System.monotonic_time(:millisecond) + 2_000, - identifier: "MT-RETRY", - error: "boom" - } - } - } - end) + test "phoenix observability api preserves state, issue, and refresh responses" do + snapshot = static_snapshot() + orchestrator_name = Module.concat(__MODULE__, :ObservabilityApiOrchestrator) - port = wait_for_bound_port(server_name) - assert HttpServer.bound_port(server_name) == port + {:ok, _pid} = + StaticOrchestrator.start_link( + name: orchestrator_name, + snapshot: snapshot, + refresh: %{ + queued: true, + coalesced: false, + requested_at: DateTime.utc_now(), + operations: ["poll", "reconcile"] + } + ) - {status, headers, body} = http_request(port, "GET", "/") - assert status == 200 - assert Map.fetch!(headers, "content-type") =~ "text/html" - assert body =~ "Symphony Dashboard" + start_test_endpoint(orchestrator: orchestrator_name, snapshot_timeout_ms: 50) - {status, headers, body} = http_request(port, "GET", "/api/v1/state") - assert status == 200 - assert Map.fetch!(headers, "content-type") =~ "application/json" + conn = get(build_conn(), "/api/v1/state") + state_payload = json_response(conn, 200) - assert %{ + assert state_payload == %{ + "generated_at" => state_payload["generated_at"], "counts" => %{"running" => 1, "retrying" => 1}, - "running" => [%{"issue_identifier" => "MT-HTTP", "last_message" => "rendered", "turn_count" => 7}], - "retrying" => [%{"issue_identifier" => "MT-RETRY", "error" => "boom"}] - } = Jason.decode!(body) - - :sys.replace_state(orchestrator_pid, fn state -> - update_in(state.running["issue-http"].last_codex_message, fn _ -> %{message: "structured"} end) - end) - - {status, _headers, body} = http_request(port, "GET", "/api/v1/MT-HTTP") - assert status == 200 - - assert %{ + "running" => [ + %{ + "issue_id" => "issue-http", + "issue_identifier" => "MT-HTTP", + "state" => "In Progress", + "session_id" => "thread-http", + "turn_count" => 7, + "last_event" => "notification", + "last_message" => "rendered", + "started_at" => state_payload["running"] |> List.first() |> Map.fetch!("started_at"), + "last_event_at" => nil, + "tokens" => %{"input_tokens" => 4, "output_tokens" => 8, "total_tokens" => 12} + } + ], + "retrying" => [ + %{ + "issue_id" => "issue-retry", + "issue_identifier" => "MT-RETRY", + "attempt" => 2, + "due_at" => state_payload["retrying"] |> List.first() |> Map.fetch!("due_at"), + "error" => "boom" + } + ], + "codex_totals" => %{ + "input_tokens" => 4, + "output_tokens" => 8, + "total_tokens" => 12, + "seconds_running" => 42.5 + }, + "rate_limits" => %{"primary" => %{"remaining" => 11}} + } + + conn = get(build_conn(), "/api/v1/MT-HTTP") + issue_payload = json_response(conn, 200) + + assert issue_payload == %{ "issue_identifier" => "MT-HTTP", + "issue_id" => "issue-http", "status" => "running", - "running" => %{"last_message" => "structured", "turn_count" => 7}, - "retry" => nil - } = Jason.decode!(body) - - {status, _headers, body} = http_request(port, "GET", "/api/v1/MT-RETRY") - assert status == 200 - assert %{"status" => "retrying", "retry" => %{"attempt" => 2}} = Jason.decode!(body) - - {status, _headers, body} = http_request(port, "GET", "/api/v1/MT-MISSING") - assert status == 404 - assert %{"error" => %{"code" => "issue_not_found"}} = Jason.decode!(body) - - {status, _headers, body} = http_request(port, "POST", "/api/v1/refresh", "") - assert status == 202 - assert %{"coalesced" => false, "operations" => ["poll", "reconcile"], "queued" => true} = Jason.decode!(body) - end - - test "http server escapes html-sensitive characters in rendered dashboard payload" do - write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") - orchestrator_name = Module.concat(__MODULE__, :EscapingHttpOrchestrator) - server_name = Module.concat(__MODULE__, :EscapingHttpServer) - {:ok, orchestrator_pid} = Orchestrator.start_link(name: orchestrator_name) - - {:ok, server_pid} = - HttpServer.start_link( - name: server_name, - host: "127.0.0.1", - port: 0, - orchestrator: orchestrator_name, - snapshot_timeout_ms: 1_000 - ) - - on_exit(fn -> - if Process.alive?(server_pid), do: Process.exit(server_pid, :normal) - if Process.alive?(orchestrator_pid), do: Process.exit(orchestrator_pid, :normal) - end) - - running_entry = %{ - pid: self(), - ref: make_ref(), - identifier: "MT-897", - issue: %Issue{id: "issue-html", identifier: "MT-897", state: "In Progress"}, - session_id: "thread-html", - turn_count: 7, - codex_app_server_pid: nil, - last_codex_message: "", - last_codex_timestamp: nil, - last_codex_event: :notification, - codex_input_tokens: 4, - codex_output_tokens: 8, - codex_total_tokens: 12, - started_at: DateTime.utc_now() - } - - :sys.replace_state(orchestrator_pid, fn state -> - %{state | running: %{"issue-html" => running_entry}, retry_attempts: %{}} - end) - - port = wait_for_bound_port(server_name) - {status, _headers, body} = http_request(port, "GET", "/") - assert status == 200 - refute String.contains?(body, "") - assert body =~ "<script>window.xssed=1</script>" + "workspace" => %{"path" => Path.join(Config.workspace_root(), "MT-HTTP")}, + "attempts" => %{"restart_count" => 0, "current_retry_attempt" => 0}, + "running" => %{ + "session_id" => "thread-http", + "turn_count" => 7, + "state" => "In Progress", + "started_at" => issue_payload["running"]["started_at"], + "last_event" => "notification", + "last_message" => "rendered", + "last_event_at" => nil, + "tokens" => %{"input_tokens" => 4, "output_tokens" => 8, "total_tokens" => 12} + }, + "retry" => nil, + "logs" => %{"codex_session_logs" => []}, + "recent_events" => [], + "last_error" => nil, + "tracked" => %{} + } + + conn = get(build_conn(), "/api/v1/MT-RETRY") + + assert %{"status" => "retrying", "retry" => %{"attempt" => 2, "error" => "boom"}} = + json_response(conn, 200) + + conn = get(build_conn(), "/api/v1/MT-MISSING") + + assert json_response(conn, 404) == %{ + "error" => %{"code" => "issue_not_found", "message" => "Issue not found"} + } + + conn = post(build_conn(), "/api/v1/refresh", %{}) + + assert %{"queued" => true, "coalesced" => false, "operations" => ["poll", "reconcile"]} = + json_response(conn, 202) end - test "http server returns method, parse, timeout, and unavailable errors" do - write_workflow_file!(Workflow.workflow_file_path(), tracker_kind: "memory") - server_name = Module.concat(__MODULE__, :ErrorHttpServer) + test "phoenix observability api preserves 405, 404, and unavailable behavior" do unavailable_orchestrator = Module.concat(__MODULE__, :UnavailableOrchestrator) + start_test_endpoint(orchestrator: unavailable_orchestrator, snapshot_timeout_ms: 5) - {:ok, server_pid} = - HttpServer.start_link( - name: server_name, - host: "127.0.0.1", - port: 0, - orchestrator: unavailable_orchestrator, - snapshot_timeout_ms: 5 - ) - - on_exit(fn -> - if Process.alive?(server_pid), do: Process.exit(server_pid, :normal) - end) - - port = wait_for_bound_port(server_name) + assert json_response(post(build_conn(), "/api/v1/state", %{}), 405) == + %{"error" => %{"code" => "method_not_allowed", "message" => "Method not allowed"}} - {status, _headers, body} = http_request(port, "POST", "/api/v1/state", "") - assert status == 405 - assert %{"error" => %{"code" => "method_not_allowed"}} = Jason.decode!(body) + assert json_response(get(build_conn(), "/api/v1/refresh"), 405) == + %{"error" => %{"code" => "method_not_allowed", "message" => "Method not allowed"}} - {status, _headers, body} = http_request(port, "GET", "/api/v1/refresh") - assert status == 405 - assert %{"error" => %{"code" => "method_not_allowed"}} = Jason.decode!(body) + assert json_response(post(build_conn(), "/", %{}), 405) == + %{"error" => %{"code" => "method_not_allowed", "message" => "Method not allowed"}} - {status, _headers, body} = http_request(port, "POST", "/", "") - assert status == 405 - assert %{"error" => %{"code" => "method_not_allowed"}} = Jason.decode!(body) + assert json_response(post(build_conn(), "/api/v1/MT-1", %{}), 405) == + %{"error" => %{"code" => "method_not_allowed", "message" => "Method not allowed"}} - {status, _headers, body} = http_request(port, "POST", "/api/v1/MT-1", "") - assert status == 405 - assert %{"error" => %{"code" => "method_not_allowed"}} = Jason.decode!(body) + assert json_response(get(build_conn(), "/unknown"), 404) == + %{"error" => %{"code" => "not_found", "message" => "Route not found"}} - {status, _headers, body} = http_request(port, "GET", "/unknown") - assert status == 404 - assert %{"error" => %{"code" => "not_found"}} = Jason.decode!(body) + state_payload = json_response(get(build_conn(), "/api/v1/state"), 200) - {status, _headers, body} = http_request(port, "GET", "/api/v1/state") - assert status == 200 - assert %{"error" => %{"code" => "snapshot_unavailable"}} = Jason.decode!(body) + assert state_payload == + %{ + "generated_at" => state_payload["generated_at"], + "error" => %{"code" => "snapshot_unavailable", "message" => "Snapshot unavailable"} + } - {status, _headers, body} = http_request(port, "POST", "/api/v1/refresh", "") - assert status == 503 - assert %{"error" => %{"code" => "orchestrator_unavailable"}} = Jason.decode!(body) - - assert http_raw_request(port, "BROKEN\r\n\r\n") =~ "400 Bad Request" + assert json_response(post(build_conn(), "/api/v1/refresh", %{}), 503) == + %{ + "error" => %{ + "code" => "orchestrator_unavailable", + "message" => "Orchestrator is unavailable" + } + } + end + test "phoenix observability api preserves snapshot timeout behavior" do timeout_orchestrator = Module.concat(__MODULE__, :TimeoutOrchestrator) - {:ok, timeout_pid} = SlowOrchestrator.start_link(name: timeout_orchestrator) + {:ok, _pid} = SlowOrchestrator.start_link(name: timeout_orchestrator) + start_test_endpoint(orchestrator: timeout_orchestrator, snapshot_timeout_ms: 1) - timeout_server_name = Module.concat(__MODULE__, :TimeoutHttpServer) + timeout_payload = json_response(get(build_conn(), "/api/v1/state"), 200) - {:ok, timeout_server_pid} = - HttpServer.start_link( - name: timeout_server_name, - host: "127.0.0.1", - port: 0, - orchestrator: timeout_orchestrator, - snapshot_timeout_ms: 1 - ) + assert timeout_payload == + %{ + "generated_at" => timeout_payload["generated_at"], + "error" => %{"code" => "snapshot_timeout", "message" => "Snapshot timed out"} + } + end - on_exit(fn -> - if Process.alive?(timeout_server_pid), do: Process.exit(timeout_server_pid, :normal) - if Process.alive?(timeout_pid), do: Process.exit(timeout_pid, :normal) - end) + test "dashboard bootstraps liveview from dependency static assets" do + orchestrator_name = Module.concat(__MODULE__, :AssetOrchestrator) - timeout_port = wait_for_bound_port(timeout_server_name) - {status, _headers, body} = http_request(timeout_port, "GET", "/api/v1/state") - assert status == 200 - assert %{"error" => %{"code" => "snapshot_timeout"}} = Jason.decode!(body) - end + {:ok, _pid} = + StaticOrchestrator.start_link( + name: orchestrator_name, + snapshot: static_snapshot(), + refresh: %{ + queued: true, + coalesced: false, + requested_at: DateTime.utc_now(), + operations: ["poll"] + } + ) - test "http server child spec, ignore branch, invalid host, and bound_port fallback behave as expected" do - spec = HttpServer.child_spec(name: :child_spec_server, port: 0) - assert spec.id == :child_spec_server - assert spec.start == {HttpServer, :start_link, [[name: :child_spec_server, port: 0]]} + start_test_endpoint(orchestrator: orchestrator_name, snapshot_timeout_ms: 50) - Application.put_env(:symphony_elixir, :server_port_override, 0) + html = html_response(get(build_conn(), "/"), 200) + assert html =~ "/dashboard.css" + assert html =~ "/vendor/phoenix_html/phoenix_html.js" + assert html =~ "/vendor/phoenix/phoenix.js" + assert html =~ "/vendor/phoenix_live_view/phoenix_live_view.js" + refute html =~ "/assets/app.js" + refute html =~ "