defmodule SelectoComponents.Debug.DebugDisplay do
@moduledoc """
LiveComponent for displaying debug information based on domain configuration.
"""
use Phoenix.LiveComponent
alias SelectoComponents.Debug.ConfigReader
def render(assigns) do
~H"""
<div
class="selecto-debug-panel"
id={"debug-panel-#{@id}"}
phx-hook="SelectoComponents.Debug.DebugDisplay.DebugClipboard"
>
<div :if={@show_debug} class="bg-gray-100 border border-gray-300 rounded-md p-3 mt-2 text-xs">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<h4 class="font-semibold text-gray-700">Debug Information</h4>
<button
type="button"
phx-click="toggle_debug_details"
phx-target={@myself}
class="inline-flex items-center px-2 py-1 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs font-medium transition-colors"
>
<%= if @expanded do %>
<svg class="h-3 w-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
Hide Details
<% else %>
<svg class="h-3 w-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
Show Details
<% end %>
</button>
</div>
<div class="text-gray-600">
{summary_text(@debug_info)}
</div>
</div>
<div :if={@expanded} class="space-y-2">
<div :if={@debug_info[:query]} class="border-t border-gray-200 pt-2">
<div class="flex items-center justify-between mb-2">
<h5 class="font-medium text-gray-600">SQL Query</h5>
<div class="flex items-center gap-2">
<!-- Copy button fixed - COMPTASK-0099 -->
<button
type="button"
phx-click="copy_sql"
phx-target={@myself}
class="inline-flex items-center px-2 py-1 bg-blue-500 hover:bg-blue-600 text-white rounded text-xs font-medium transition-colors"
title="Copy SQL to clipboard"
>
<svg class="h-3 w-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
Copy
</button>
<button
:if={@debug_info[:params] && length(@debug_info.params) > 0}
type="button"
phx-click="toggle_sql_mode"
phx-target={@myself}
class="inline-flex items-center px-2 py-1 bg-gray-500 hover:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
<%= if @show_interpolated do %>
Show Parameterized
<% else %>
Show Interpolated
<% end %>
</button>
</div>
</div>
<%= if @show_interpolated && @debug_info[:params] do %>
<div class="bg-gray-900 p-3 rounded border border-gray-700 overflow-x-auto">
{Phoenix.HTML.raw(
format_sql_with_makeup(interpolate_params(@debug_info.query, @debug_info.params))
)}
</div>
<div class="mt-2 text-xs text-gray-600">
<span class="font-semibold">Note:</span>
This interpolated query can be copied and pasted directly into psql or other SQL tools.
</div>
<% else %>
<div class="bg-gray-900 p-3 rounded border border-gray-700 overflow-x-auto">
{Phoenix.HTML.raw(format_sql_with_makeup(@debug_info.query))}
</div>
<% end %>
</div>
<.debug_section
:if={@debug_info[:params] && !@show_interpolated}
title="Parameters"
content={@debug_info.params}
type="list"
/>
<.debug_section
:if={@debug_info[:timing]}
title="Execution Time"
content={format_timing(@debug_info.timing)}
type="text"
/>
<.debug_section
:if={@debug_info[:row_count]}
title="Row Count"
content={@debug_info.row_count}
type="text"
/>
<.debug_section
:if={not is_nil(@debug_info[:page_cache_memory_bytes])}
title="Page Cache Memory"
content={format_page_cache_memory(@debug_info)}
type="text"
/>
<.debug_section
:if={@debug_info[:execution_plan]}
title="Execution Plan"
content={@debug_info.execution_plan}
type="code"
/>
<.debug_metadata metadata={@metadata} />
</div>
</div>
<script :type={Phoenix.LiveView.ColocatedHook} name=".DebugClipboard">
export default {
mounted() {
this.handleCopyEvent = (e) => {
const button = e.target.closest('button[phx-click="copy_sql"]');
if (button) {
const sqlQuery = this.el.querySelector('[data-sql-query]')?.textContent ||
this.el.querySelector('pre')?.textContent || '';
if (sqlQuery) {
navigator.clipboard.writeText(sqlQuery).then(() => {
const originalText = button.innerHTML;
button.innerHTML = '✓ Copied!';
button.classList.add('bg-green-500');
button.classList.remove('bg-blue-500');
setTimeout(() => {
button.innerHTML = originalText;
button.classList.remove('bg-green-500');
button.classList.add('bg-blue-500');
}, 2000);
});
}
}
};
this.el.addEventListener('click', this.handleCopyEvent);
},
destroyed() {
if (this.handleCopyEvent) {
this.el.removeEventListener('click', this.handleCopyEvent);
}
}
}
</script>
</div>
"""
end
def debug_section(assigns) do
~H"""
<div class="border-t border-gray-200 pt-2">
<h5 class="font-medium text-gray-600 mb-1">{@title}</h5>
<%= case @type do %>
<% "code" -> %>
<%= if @title == "SQL Query" do %>
<div class="bg-gray-900 p-3 rounded border border-gray-700 overflow-x-auto">
{Phoenix.HTML.raw(format_sql_with_makeup(@content))}
</div>
<% else %>
<pre class="bg-gray-50 p-3 rounded border border-gray-200 overflow-x-auto">
<code class="text-xs font-mono text-gray-800"><%= @content %></code>
</pre>
<% end %>
<% "list" -> %>
<%= if @title == "Parameters" do %>
<ul class="bg-white p-2 rounded border border-gray-200">
<%= for {item, index} <- Enum.with_index(@content, 1) do %>
<li class="text-xs font-mono">
<span class="text-blue-600 font-semibold">${index}</span>
<span class="text-gray-500 mx-1">=</span>
<span class="text-gray-800">{format_param_value(item)}</span>
</li>
<% end %>
</ul>
<% else %>
<ul class="bg-white p-2 rounded border border-gray-200">
<%= for {item, index} <- Enum.with_index(@content) do %>
<li class="text-xs">
<span class="text-gray-500">[{index}]</span>
{inspect(item, pretty: true, limit: 50)}
</li>
<% end %>
</ul>
<% end %>
<% _ -> %>
<div class="bg-white p-2 rounded border border-gray-200 text-xs">
{@content}
</div>
<% end %>
</div>
"""
end
def debug_metadata(assigns) do
~H"""
<div :if={@metadata && map_size(@metadata) > 0} class="border-t border-gray-200 pt-2">
<h5 class="font-medium text-gray-600 mb-1">Metadata</h5>
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<%= for {key, value} <- @metadata do %>
<dt class="text-gray-500">{humanize_key(key)}:</dt>
<dd class="text-gray-700">{format_metadata_value(value)}</dd>
<% end %>
</dl>
</div>
"""
end
def mount(socket) do
{:ok,
assign(socket,
expanded: true,
show_debug: false,
show_interpolated: false,
debug_info: %{},
metadata: %{}
)}
end
def update(assigns, socket) do
domain_module = Map.get(assigns, :domain_module)
view_type = Map.get(assigns, :view_type)
config = ConfigReader.get_view_config(domain_module, view_type)
show_debug = ConfigReader.debug_enabled?(domain_module, view_type)
debug_info =
if show_debug && assigns[:debug_data] do
ConfigReader.build_debug_info(assigns.debug_data, config)
else
%{}
end
metadata = extract_metadata(assigns)
{:ok,
socket
|> assign(assigns)
|> assign(
show_debug: show_debug,
debug_info: debug_info,
metadata: metadata,
config: config
)}
end
def handle_event("toggle_debug_details", _, socket) do
{:noreply, assign(socket, expanded: !socket.assigns.expanded)}
end
def handle_event("toggle_sql_mode", _, socket) do
{:noreply, assign(socket, show_interpolated: !socket.assigns.show_interpolated)}
end
def handle_event("copy_sql", _params, socket) do
sql_to_copy =
if socket.assigns.show_interpolated do
# Get interpolated SQL
case socket.assigns.debug_info do
%{query: query, params: params} when is_binary(query) and is_list(params) ->
interpolate_params(query, params)
%{query: query} when is_binary(query) ->
query
_ ->
""
end
else
# Get raw SQL
socket.assigns.debug_info[:query] || ""
end
# Push event to JavaScript hook to handle clipboard
{:noreply, push_event(socket, "copy-to-clipboard", %{text: sql_to_copy})}
end
# Helper functions
defp format_timing(timing) when is_number(timing) do
cond do
timing < 1 -> "< 1ms"
timing < 1000 -> "#{round(timing)}ms"
true -> "#{Float.round(timing / 1000, 2)}s"
end
end
defp format_timing(timing), do: inspect(timing)
defp summary_text(debug_info) do
parts = []
parts =
if debug_info[:timing] do
["Executed in #{format_timing(debug_info.timing)}" | parts]
else
parts
end
parts =
if debug_info[:row_count] do
["#{debug_info.row_count} rows" | parts]
else
parts
end
parts =
if not is_nil(debug_info[:page_cache_memory_bytes]) do
["cache #{format_bytes(debug_info.page_cache_memory_bytes)}" | parts]
else
parts
end
if Enum.empty?(parts) do
"Click to expand debug information"
else
Enum.join(parts, " • ")
end
end
defp extract_metadata(assigns) do
%{}
|> maybe_add_metadata(:domain, assigns[:domain_module])
|> maybe_add_metadata(:view_type, assigns[:view_type])
|> maybe_add_metadata(:filters_count, count_filters(assigns[:filters]))
|> maybe_add_metadata(:aggregates_count, count_items(assigns[:aggregates]))
|> maybe_add_metadata(:columns_count, count_items(assigns[:columns]))
end
defp maybe_add_metadata(metadata, _key, nil), do: metadata
defp maybe_add_metadata(metadata, _key, ""), do: metadata
defp maybe_add_metadata(metadata, key, value), do: Map.put(metadata, key, value)
defp count_filters(nil), do: nil
defp count_filters(filters) when is_list(filters), do: length(filters)
defp count_filters(filters) when is_map(filters), do: map_size(filters)
defp count_filters(_), do: nil
defp count_items(nil), do: nil
defp count_items(items) when is_list(items), do: length(items)
defp count_items(items) when is_map(items), do: map_size(items)
defp count_items(_), do: nil
defp humanize_key(key) when is_atom(key) do
key
|> Atom.to_string()
|> String.replace("_", " ")
|> String.capitalize()
end
defp humanize_key(key), do: to_string(key)
defp format_metadata_value(value) when is_atom(value) do
value
|> Atom.to_string()
|> String.replace("_", " ")
end
defp format_metadata_value(value) when is_number(value), do: to_string(value)
defp format_metadata_value(value), do: inspect(value, limit: 20)
defp format_page_cache_memory(debug_info) do
bytes = Map.get(debug_info, :page_cache_memory_bytes, 0)
pages = Map.get(debug_info, :page_cache_pages)
rows = Map.get(debug_info, :page_cache_rows)
parts = [format_bytes(bytes)]
parts = if is_integer(pages), do: ["#{pages} pages" | parts], else: parts
parts = if is_integer(rows), do: ["#{rows} cached rows" | parts], else: parts
Enum.reverse(parts)
|> Enum.join(" • ")
end
defp format_bytes(bytes) when is_integer(bytes) and bytes >= 1024 * 1024 do
"#{Float.round(bytes / (1024 * 1024), 2)} MB"
end
defp format_bytes(bytes) when is_integer(bytes) and bytes >= 1024 do
"#{Float.round(bytes / 1024, 2)} KB"
end
defp format_bytes(bytes) when is_integer(bytes) and bytes >= 0 do
"#{bytes} B"
end
defp format_bytes(_bytes), do: "0 B"
defp format_param_value(value) when is_binary(value) do
if String.length(value) > 50 do
"'#{String.slice(value, 0, 50)}...'"
else
"'#{value}'"
end
end
defp format_param_value(value) when is_nil(value), do: "NULL"
defp format_param_value(value) when is_boolean(value), do: if(value, do: "TRUE", else: "FALSE")
defp format_param_value(value) when is_number(value), do: to_string(value)
defp format_param_value(value), do: inspect(value, limit: 50)
defp interpolate_params(sql, params) when is_binary(sql) and is_list(params) do
# Replace $1, $2, etc. with actual parameter values
params
|> Enum.with_index(1)
|> Enum.reduce(sql, fn {value, index}, acc ->
# Escape the parameter value for SQL
escaped_value = escape_sql_value(value)
String.replace(acc, "$#{index}", escaped_value)
end)
end
defp interpolate_params(sql, _), do: sql
defp escape_sql_value(nil), do: "NULL"
defp escape_sql_value(true), do: "TRUE"
defp escape_sql_value(false), do: "FALSE"
defp escape_sql_value(value) when is_number(value), do: to_string(value)
defp escape_sql_value(value) when is_binary(value) do
# Escape single quotes by doubling them
escaped = String.replace(value, "'", "''")
"'#{escaped}'"
end
defp escape_sql_value(%DateTime{} = dt), do: "'#{DateTime.to_iso8601(dt)}'"
defp escape_sql_value(%Date{} = d), do: "'#{Date.to_iso8601(d)}'"
defp escape_sql_value(%Time{} = t), do: "'#{Time.to_iso8601(t)}'"
defp escape_sql_value(value) when is_list(value) do
# Handle arrays/lists
items = Enum.map(value, &escape_sql_value/1)
"ARRAY[#{Enum.join(items, ", ")}]"
end
defp escape_sql_value(value), do: "'#{inspect(value)}'"
defp format_sql_with_makeup(sql) when is_binary(sql) do
# Use Makeup to format and highlight SQL
# First, ensure MakeupSQL lexer is registered
Makeup.Registry.fetch_lexer_by_name!("sql")
# Format the SQL with Makeup
sql
|> Makeup.highlight(lexer: "sql")
|> add_makeup_styles()
rescue
_ ->
# Fallback to simple HTML escaping if Makeup fails
"<pre class=\"text-xs font-mono text-gray-300\">#{Phoenix.HTML.html_escape(sql) |> Phoenix.HTML.safe_to_string()}</pre>"
end
defp format_sql_with_makeup(_), do: ""
# Add inline styles for Makeup tokens since we're in a component
defp add_makeup_styles(html) do
"""
<style>
.highlight { font-family: monospace; font-size: 0.75rem; line-height: 1.25rem; color: #e5e7eb; }
.highlight pre { margin: 0; white-space: pre; }
.highlight [class^="k"] { color: #93c5fd; font-weight: 600; } /* Keywords */
.highlight [class^="o"] { color: #9ca3af; } /* Operators */
.highlight [class^="s"] { color: #fde047; } /* Strings */
.highlight [class^="n"] { color: #e5e7eb; } /* Names */
.highlight [class^="m"] { color: #67e8f9; } /* Numbers */
.highlight [class^="c"] { color: #6b7280; font-style: italic; } /* Comments */
.highlight .p { color: #9ca3af; } /* Punctuation */
.highlight .w { color: inherit; } /* Whitespace */
</style>
<div class="highlight">#{html}</div>
"""
end
end