defmodule PhoenixGenApiTui do @moduledoc """ Terminal-based interactive explorer for PhoenixGenApi applications. `phoenix_gen_api_tui` provides a navigable two-panel TUI for discovering services, function configs, call flows, cluster topology, rate limits, and runtime health in any PhoenixGenApi project. ## Usage Add `phoenix_gen_api_tui` to your dependencies: def deps do [ {:phoenix_gen_api_tui, "~> 0.1.0"} ] end ### From IEx (recommended for runtime inspection) iex> PhoenixGenApiTui.ui() ### Inspect a remote node iex> PhoenixGenApiTui.ui(remote_node: :"app@remote-host") ### Programmatic usage PhoenixGenApiTui.explore() PhoenixGenApiTui.explore(transport: :ssh) PhoenixGenApiTui.explore(transport: :distributed) ## Transports The same explorer can be served locally, over SSH, or over Erlang distribution — powered by [ExRatatui](https://hexdocs.pm/ex_ratatui) transports. See `explore/1` for options. ### Local (default) PhoenixGenApiTui.ui() # or PhoenixGenApiTui.explore() ### SSH PhoenixGenApiTui.explore(transport: :ssh) # then: ssh phoenix@localhost -p 4546 (password: tui) ### Erlang Distribution # Terminal 1 — start the listener iex --sname app --cookie demo -S mix iex> PhoenixGenApiTui.explore(transport: :distributed) # Terminal 2 — attach from another node iex --sname local --cookie demo -S mix iex> ExRatatui.Distributed.attach(:"app@hostname", PhoenixGenApiTui.App) """ @doc """ Launches the PhoenixGenApi TUI explorer in the local terminal. This is the primary entry point for interactive use from IEx. Reads runtime configuration from the connected PhoenixGenApi application. **Note:** This function blocks the calling process (IEx) until the TUI exits. The TUI takes over the terminal. Press `q` to quit and return to IEx. ## Options * `:remote_node` — query PhoenixGenApi data from a remote node via RPC. * `:timeout` — RPC timeout in milliseconds (default `10_000`). ## Examples iex> PhoenixGenApiTui.ui() iex> PhoenixGenApiTui.ui(remote_node: :"app@remote-host") iex> PhoenixGenApiTui.ui(remote_node: :"app@remote-host", timeout: 5_000) ## Non-blocking usage To run the TUI in a background process (keeping IEx responsive), use `run/1` instead: iex> {:ok, pid} = PhoenixGenApiTui.run() iex> # TUI is running in background, IEx is still usable iex> # Press `q` in the TUI terminal to stop it """ @spec ui(keyword()) :: :ok | {:error, term()} def ui(opts \\ []) do explore([{:transport, :local} | opts]) end @doc """ Starts the PhoenixGenApi TUI explorer in a background process. Unlike `ui/1`, this function returns immediately with the PID of the TUI process, keeping the calling process (IEx) responsive. The TUI runs in a separate process and takes over the terminal. Press `q` in the TUI to stop it, or call `stop/1` with the PID. ## Options Same as `ui/1`. ## Examples iex> {:ok, pid} = PhoenixGenApiTui.run() iex> PhoenixGenApiTui.stop(pid) """ @spec run(keyword()) :: {:ok, pid()} | {:error, term()} def run(opts \\ []) do Task.start_link(fn -> explore([{:transport, :local} | opts]) end) end @doc """ Stops a running TUI process started with `run/1`. ## Examples iex> {:ok, pid} = PhoenixGenApiTui.run() iex> PhoenixGenApiTui.stop(pid) :ok """ @spec stop(pid()) :: :ok def stop(pid) do Process.exit(pid, :normal) :ok end @doc """ Launches the PhoenixGenApi TUI explorer with options. Loads all PhoenixGenApi runtime config data via `PhoenixGenApiTui.Introspection`, then starts an interactive terminal interface. ## Options * `:transport` — `:local` (default), `:ssh`, or `:distributed`. * `:force_refresh` — bypass cache and fetch fresh data on start (default `false`). * `:remote_node` — query PhoenixGenApi data from a remote node via RPC. * `:timeout` — RPC timeout in milliseconds (default `10_000`). ### Local options Any extra options are forwarded to `PhoenixGenApiTui.App` (e.g. `test_mode: {80, 24}`, `name: nil`). ### SSH options When `transport: :ssh`, these options configure the SSH daemon: * `:port` — TCP port (default `4546`). * `:auto_host_key` — generate a host key automatically (default `true`). * `:auth_methods` — e.g. `~c"password"` (default). * `:user_passwords` — e.g. `[{~c"phoenix", ~c"tui"}]` (default). Any other keyword is forwarded to `:ssh.daemon/2`. See the [ExRatatui SSH guide](https://hexdocs.pm/ex_ratatui/ssh_transport.html) for the full option reference. ### Distributed options When `transport: :distributed`, the function starts a listener that remote nodes attach to via `ExRatatui.Distributed.attach/3`: ExRatatui.Distributed.attach(:"app@hostname", PhoenixGenApiTui.App) See the [ExRatatui Distribution guide](https://hexdocs.pm/ex_ratatui/distributed_transport.html) for details. ## Examples # Local (default) PhoenixGenApiTui.explore() # SSH with defaults (port 4546, phoenix:tui password) PhoenixGenApiTui.explore(transport: :ssh) # SSH with custom port and credentials PhoenixGenApiTui.explore( transport: :ssh, port: 4000, user_passwords: [{~c"admin", ~c"secret"}] ) # Distributed listener PhoenixGenApiTui.explore(transport: :distributed) # Query remote node PhoenixGenApiTui.explore(remote_node: :"app@remote-host") # Force fresh data load PhoenixGenApiTui.explore(force_refresh: true) """ @spec explore(keyword()) :: :ok | {:error, term()} def explore(opts \\ []) do force_refresh = Keyword.get(opts, :force_refresh, false) # Build introspection options (for remote node queries) intro_opts = Keyword.take(opts, [:node, :timeout]) data = if force_refresh do PhoenixGenApiTui.Introspection.load!(intro_opts) else PhoenixGenApiTui.Introspection.load(intro_opts) end log_load_status(data) if data.services == [] do IO.puts(:stderr, """ Warning: No PhoenixGenApi services found. Make sure your PhoenixGenApi application is running and PhoenixGenApi.ConfigDb has registered services. Press 'r' to refresh, 'q' to quit. """) end state = PhoenixGenApiTui.State.new(data) # The process starter is read from config so the boot path can be # exercised in tests without a real terminal; It defaults to the live # `PhoenixGenApiTui.App.start_link/1`. starter = Application.get_env(:phoenix_gen_api_tui, :app_starter, &PhoenixGenApiTui.App.start_link/1) case starter.(build_start_opts(state, opts)) do {:ok, pid} -> ref = Process.monitor(pid) receive do {:DOWN, ^ref, :process, ^pid, _reason} -> :ok end {:error, reason} -> IO.puts(:stderr, "[PhoenixGenApiTUI] Failed to start TUI: #{inspect(reason)}") {:error, reason} end end defp log_load_status(%PhoenixGenApiTui.Introspection{status: status}) do case status do :partial -> IO.puts(:stderr, "[PhoenixGenApiTUI] Warning: Some subsystems unavailable (partial data)") :error -> IO.puts(:stderr, "[PhoenixGenApiTUI] Error: Failed to load introspection data") _ -> :ok end end @doc false @spec build_start_opts(PhoenixGenApiTui.State.t(), keyword()) :: keyword() def build_start_opts(state, opts) do transport = Keyword.get(opts, :transport, :local) if transport == :local do [{:state, state} | opts] else app_opts = [{:state, state} | Keyword.get(opts, :app_opts, [])] opts = Keyword.put(opts, :app_opts, app_opts) case transport do :ssh -> ssh_defaults(opts) :distributed -> opts end end end @doc """ Applies default SSH options to the given keyword list. Defaults (all overridable via `opts`): * `:port` — `4546` * `:auto_host_key` — `true` * `:auth_methods` — `~c"password"` * `:user_passwords` — `[{~c"phoenix", ~c"tui"}]` """ @spec ssh_defaults(keyword()) :: keyword() def ssh_defaults(opts) do opts |> Keyword.put_new(:port, 4546) |> Keyword.put_new(:auto_host_key, true) |> Keyword.put_new(:auth_methods, ~c"password") |> Keyword.put_new(:user_passwords, [{~c"phoenix", ~c"tui"}]) end end