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
try do
do_explore(opts)
catch
:exit, reason ->
IO.puts(
:stderr,
"[PhoenixGenApiTUI] Cannot connect to required process: #{inspect(reason)}"
)
IO.puts(
:stderr,
"[PhoenixGenApiTUI] Make sure PhoenixGenApi application is started before running the TUI."
)
{:error, {:exit, reason}}
end
end
defp do_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