Current section
Files
Jump to
Current section
Files
lib/phoenix_gen_api_tui/app.ex
defmodule PhoenixGenApiTui.App do
@moduledoc """
Main TUI application using `ExRatatui.App` behaviour.
Renders the two-panel layout and delegates key handling to `PhoenixGenApiTui.State`.
## Transports
This module works across all three ExRatatui transports without changes:
* **Local** ā `PhoenixGenApiTui.App.start_link(state: state)` renders to the
local terminal.
* **SSH** ā `PhoenixGenApiTui.App.start_link(transport: :ssh, app_opts: [state: state], ...)`
serves each SSH client an isolated explorer session.
* **Distributed** ā `PhoenixGenApiTui.App.start_link(transport: :distributed, app_opts: [state: state])`
starts a listener; remote nodes attach via
`ExRatatui.Distributed.attach(node, PhoenixGenApiTui.App)`.
In practice, use `PhoenixGenApiTui.explore/2` which handles the wiring for you.
"""
use ExRatatui.App
alias PhoenixGenApiTui.State
alias PhoenixGenApiTui.Telemetry
alias PhoenixGenApiTui.Theme
alias PhoenixGenApiTui.Views.{
NavPanel,
FunctionsTab,
FunctionDetail,
CallFlowsTab,
ClusterTab,
HealthTab,
StatsTab,
RateLimitsTab,
ErrorsTab,
SystemTab
}
alias ExRatatui.Layout
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Text.{Line, Span}
alias ExRatatui.Widgets.{Block, Paragraph, Tabs}
require Logger
# āā Callbacks āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
@impl true
def mount(opts) do
Telemetry.execute([:phoenix_gen_api_tui, :mount, :start], %{}, %{})
state = Keyword.fetch!(opts, :state)
search_input = ExRatatui.text_input_new()
{:ok, %{state | search_input: search_input}}
end
@impl true
def render(state, frame) do
area = %Rect{x: 0, y: 0, width: frame.width, height: frame.height}
try do
if state.show_help do
render_help(area)
else
render_explorer(state, area)
end
rescue
e ->
Logger.error("Render error: #{Exception.message(e)}\n#{Exception.format_stacktrace(__STACKTRACE__)}")
[{error_widget(e, area)}]
end
end
defp error_widget(e, area) do
widget = %Paragraph{
text: "Render error: #{Exception.message(e)}",
style: %Style{fg: :red}
}
{widget, area}
end
@impl true
def handle_event(
%ExRatatui.Event.Key{code: "q", kind: "press"},
%{show_help: false, detail_overlay: nil, searching: false} = state
) do
{:stop, state}
end
def handle_event(%ExRatatui.Event.Key{code: code, kind: "press"}, state) do
{:noreply, State.handle_key(state, code)}
end
def handle_event(_event, state) do
{:noreply, state}
end
@impl true
def terminate(reason, _state) do
# Clear the terminal before exiting to avoid blank screen on resume
IO.write("\e[2J\e[H")
Telemetry.execute([:phoenix_gen_api_tui, :mount, :stop], %{}, %{reason: reason})
:ok
end
# āā Layout āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
defp render_explorer(state, area) do
[header_area, body_area, footer_area] =
Layout.split(area, :vertical, [
{:length, 3},
{:min, 0},
{:length, 3}
])
[nav_area, detail_area] =
Layout.split(body_area, :horizontal, [
{:percentage, 25},
{:percentage, 75}
])
header_widgets = render_header(state, header_area)
nav_widgets = NavPanel.render(state, nav_area)
detail_widgets = render_detail(state, detail_area)
footer_widgets = render_footer(state, footer_area)
overlay_widgets =
if state.detail_overlay do
FunctionDetail.render(state.detail_overlay, area)
else
[]
end
header_widgets ++ nav_widgets ++ detail_widgets ++ footer_widgets ++ overlay_widgets
end
defp render_header(state, rect) do
title = State.breadcrumb(state)
brand = Theme.brand_title(title)
status_indicator = case state.introspection.status do
:ok -> " ā"
:partial -> " ā "
:error -> " ļæ½"
_ -> ""
end
notice_text = case {state.notice, state.last_error} do
{nil, nil} -> ""
{msg, nil} when is_binary(msg) -> " ā #{msg}"
{_, err} when is_binary(err) -> " ā #{err}"
_ -> ""
end
# Show remote node indicator if querying a remote node
remote_text =
case state.introspection.node do
nil -> ""
node ->
if node == Node.self() do
""
else
" @ #{node}"
end
end
line = %Line{
spans: brand.spans ++ [
%Span{content: status_indicator, style: status_style(state.introspection.status)},
%Span{content: notice_text, style: %Style{fg: Theme.dim_text()}},
%Span{content: remote_text, style: %Style{fg: Theme.cornflower()}}
]
}
header = %Paragraph{
text: line,
block: %Block{
borders: [:all],
border_type: :rounded,
border_style: %Style{fg: Theme.phoenix_pink()}
}
}
[{header, rect}]
end
defp status_style(:ok), do: %Style{fg: :green}
defp status_style(:partial), do: %Style{fg: :yellow}
defp status_style(:error), do: %Style{fg: :red}
defp status_style(_), do: %Style{fg: :white}
defp render_detail(state, rect) do
[tabs_area, content_area] =
Layout.split(rect, :vertical, [
{:length, 3},
{:min, 0}
])
tab_widgets = render_tabs(state, tabs_area)
content_widgets = render_tab_content(state, content_area)
tab_widgets ++ content_widgets
end
defp render_tabs(state, rect) do
tab_index =
Enum.find_index(
[:functions, :call_flows, :cluster, :health, :stats, :rate_limits, :errors, :system],
&(&1 == state.current_tab)
)
name =
case state.current_service do
nil -> ""
service -> to_string(service.name)
end
tab_bar = %Tabs{
titles: tab_titles(),
selected: tab_index,
style: %Style{fg: :white},
highlight_style: %Style{fg: Theme.gold(), modifiers: [:bold]},
block: %Block{
title: Theme.resource_title(name),
borders: [:all],
border_type: :rounded,
border_style: Theme.border_style(state.focus == :detail)
}
}
[{tab_bar, rect}]
end
defp tab_titles do
[
%Span{content: "Functions", style: %Style{fg: :white}},
%Span{content: "Call Flows", style: %Style{fg: :white}},
%Span{content: "Cluster", style: %Style{fg: :white}},
%Span{content: "Health", style: %Style{fg: :white}},
%Span{content: "Stats", style: %Style{fg: :white}},
%Span{content: "Rate Limits", style: %Style{fg: :white}},
%Span{content: "Errors", style: %Style{fg: :white}},
%Span{content: "System", style: %Style{fg: :white}}
]
end
defp render_tab_content(state, rect) do
case state.current_tab do
:functions -> FunctionsTab.render(state, rect)
:call_flows -> CallFlowsTab.render(state, rect)
:cluster -> ClusterTab.render(state, rect)
:health -> HealthTab.render(state, rect)
:stats -> StatsTab.render(state, rect)
:rate_limits -> RateLimitsTab.render(state, rect)
:errors -> ErrorsTab.render(state, rect)
:system -> SystemTab.render(state, rect)
end
end
defp render_footer(state, rect) do
footer = %Paragraph{
text: footer_line(state),
block: %Block{
borders: [:all],
border_type: :rounded,
border_style: Theme.unfocused_border_style()
}
}
[{footer, rect}]
end
defp footer_line(%{searching: true}) do
Theme.footer_line([
{"a-z", "type to filter"},
{"ā", "confirm"},
{"Esc", "cancel", :quit}
])
end
defp footer_line(state) do
base = [
{"j/k", "navigate"},
{"ā", "select"},
{"h/l", "panels"},
{"Tab", "tabs"},
{"1-8", "tab select"},
{"/", "search"},
{"r", "refresh"},
{"Esc", "back"},
{"?", "help"},
{"q", "quit", :quit}
]
# Show status indicator if data is partial/error
entries =
case state.introspection.status do
:partial ->
base ++ [{"", " "}, {"ā ", "partial data", :quit}]
:error ->
base ++ [{"", " "}, {"ā", "connection error", :quit}]
_ ->
base
end
Theme.footer_line(entries)
end
# āā Help Overlay āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
defp render_help(area) do
help = %Paragraph{
text: help_lines(),
block: %Block{
title: help_title(),
borders: [:all],
border_type: :double,
border_style: %Style{fg: Theme.phoenix_pink()},
style: %Style{bg: Theme.overlay_bg()}
}
}
[{help, area}]
end
defp help_title do
%Line{
spans: [
%Span{content: " š„ ", style: %Style{}},
%Span{content: "Keyboard Reference ", style: %Style{fg: :white, modifiers: [:bold]}}
]
}
end
defp help_lines do
[
blank_line(),
help_section("Navigation"),
help_row("j / ā", "Move selection down"),
help_row("k / ā", "Move selection up"),
help_row("h / ā", "Focus navigation panel"),
help_row("l / ā", "Focus detail panel"),
help_row("ā", "Select / drill into item"),
help_row("Esc", "Go back"),
blank_line(),
help_section("Tabs"),
help_row("Tab", "Cycle through tabs"),
help_row("1", "Functions tab"),
help_row("2", "Call Flows tab"),
help_row("3", "Cluster tab"),
help_row("4", "Health tab"),
help_row("5", "Stats tab"),
help_row("6", "Rate Limits tab"),
help_row("7", "Errors tab"),
help_row("8", "System tab"),
blank_line(),
help_section("Search"),
help_row("/", "Start filtering services"),
help_row("ā", "Accept filter"),
help_row("Esc", "Clear filter and cancel"),
blank_line(),
help_section("Other"),
help_row("?", "Toggle this help"),
help_row("q", "Quit", :quit),
blank_line(),
%Line{
spans: [
%Span{
content: " Press any key to close this help.",
style: %Style{fg: Theme.dim_text()}
}
]
}
]
end
defp help_section(label) do
%Line{
spans: [
%Span{content: " āā ", style: %Style{fg: Theme.dim_text()}},
%Span{content: label, style: %Style{fg: Theme.cornflower(), modifiers: [:bold]}},
%Span{content: " āā", style: %Style{fg: Theme.dim_text()}}
]
}
end
defp help_row(keys, description, kind \\ :default) do
%Line{
spans: [
%Span{content: " ", style: %Style{}},
Theme.key_pill(keys, kind),
%Span{content: " ", style: %Style{}},
%Span{content: description, style: %Style{fg: :white}}
]
}
end
defp blank_line, do: %Line{spans: [%Span{content: "", style: %Style{}}]}
end