Packages
AI module for PhoenixKit — endpoints, prompts, completions, and usage tracking
Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_ai/web/endpoints.html.heex
<div class="flex flex-col w-full px-4 py-6">
<% {header_title, header_subtitle} =
case @active_tab do
"usage" ->
{gettext("AI Usage"), gettext("Monitor API requests, token usage, and costs")}
_ ->
{gettext("AI Endpoints"),
gettext("Manage AI provider endpoints and API configurations")}
end %>
<.admin_page_header title={header_title} subtitle={header_subtitle} />
<%= if @active_tab == "endpoints" do %>
<%!-- Endpoints List --%>
<%= if @has_endpoints do %>
<% manual_sort? = @sort_by == :sort_order %>
<.table_default
id="ai-endpoints"
toggleable
items={@endpoints}
item_id={& &1.uuid}
card_class={fn ep -> "card bg-base-200 #{if !ep.enabled, do: "opacity-60"}" end}
variant="zebra"
size="sm"
on_reorder={if manual_sort?, do: "reorder_endpoints"}
>
<:sort_bar>
<%!-- Always-visible sort UI. Picking "Manual" swaps the
direction toggle for a drag-handle hint and enables
drag-to-reorder on rows + cards. --%>
<.sort_selector
sort_by={@sort_by}
sort_dir={@sort_dir}
options={@sort_options}
manual_field={:sort_order}
/>
</:sort_bar>
<:toolbar_actions>
<.link
navigate={PhoenixKitAI.Routes.ai_path() <> "/endpoints/new"}
class="btn btn-primary btn-sm"
>
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> {gettext("New Endpoint")}
</.link>
</:toolbar_actions>
<:card_body :let={endpoint}>
<% stats =
Map.get(@endpoint_stats, endpoint.uuid, %{
request_count: 0,
total_tokens: 0,
total_cost: 0
}) %>
<% integration = Map.get(@integrations_by_uuid, endpoint.integration_uuid) %>
<% integration_status = integration && integration.data["status"] %>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 flex-wrap">
<.link
navigate={PhoenixKitAI.Routes.ai_path() <> "/endpoints/#{endpoint.uuid}/edit"}
class="font-semibold text-lg hover:text-primary hover:underline"
>
{endpoint.name}
</.link>
<.enabled_badge enabled={endpoint.enabled} />
<%= cond do %>
<% endpoint.integration_uuid && is_nil(integration) -> %>
<span class="badge badge-error badge-sm" title={gettext("The pinned integration was deleted. Edit the endpoint and select a new one.")}>
{gettext("Integration missing")}
</span>
<% integration_status == "error" -> %>
<span class="badge badge-error badge-sm" title={gettext("The integration's last validation failed. Open it in Settings → Integrations to retest.")}>
{gettext("Integration error")}
</span>
<% integration && integration_status != "connected" -> %>
<span class="badge badge-warning badge-sm" title={gettext("The integration is not currently connected. Open it in Settings → Integrations to test.")}>
{gettext("Not connected")}
</span>
<% is_nil(endpoint.integration_uuid) -> %>
<span class="badge badge-warning badge-sm" title={gettext("No integration selected. Edit the endpoint to pick one.")}>
{gettext("No integration")}
</span>
<% true -> %>
<% end %>
<span class="badge badge-outline">{endpoint.provider}</span>
</div>
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-base-content/60">
<div class="flex items-center gap-1">
<.icon name="hero-link" class="w-3.5 h-3.5" />
<span class="font-medium text-base-content/70">{gettext("Integration:")}</span>
<%= cond do %>
<% integration -> %>
<span>{integration.name}</span>
<% endpoint.integration_uuid -> %>
<span class="text-error">{gettext("Deleted")}</span>
<% true -> %>
<span class="italic">{gettext("none")}</span>
<% end %>
</div>
<div class="flex items-center gap-1">
<.icon name="hero-key" class="w-3.5 h-3.5" />
<span class="font-medium text-base-content/70">{gettext("Key:")}</span>
<span class="font-mono">
{if integration,
do: PhoenixKitAI.Endpoint.masked_api_key(integration.data["api_key"]),
else: "—"}
</span>
</div>
</div>
<div class="mt-2 flex flex-wrap gap-4 text-sm text-base-content/70">
<div class="flex items-center gap-1">
<.icon name="hero-cpu-chip" class="w-4 h-4" />
<span>{PhoenixKitAI.Endpoint.short_model_name(endpoint.model)}</span>
</div>
<%= if endpoint.temperature do %>
<div class="flex items-center gap-1">
<.icon name="hero-fire" class="w-4 h-4" />
<span>Temp: {endpoint.temperature}</span>
</div>
<% end %>
<%= if endpoint.max_tokens do %>
<div class="flex items-center gap-1">
<.icon name="hero-document-text" class="w-4 h-4" />
<span>Max: {endpoint.max_tokens}</span>
</div>
<% end %>
</div>
<div class="mt-2 flex flex-wrap gap-4 text-sm">
<div class="flex items-center gap-1 text-base-content/70" title="Total requests">
<.icon name="hero-arrow-path" class="w-4 h-4" />
<span>{stats.request_count} requests</span>
</div>
<div class="flex items-center gap-1 text-base-content/70" title="Total tokens">
<.icon name="hero-document-text" class="w-4 h-4" />
<span>{PhoenixKitAI.Request.format_tokens(stats.total_tokens)} tokens</span>
</div>
<div class="flex items-center gap-1 text-base-content/70" title="Total cost">
<.icon name="hero-currency-dollar" class="w-4 h-4" />
<span>{PhoenixKitAI.Request.format_cost(stats.total_cost)}</span>
</div>
<div class="flex items-center gap-1 text-base-content/70" title="Last used">
<.icon name="hero-clock" class="w-4 h-4" />
<span>
<%= if stats[:last_used_at] do %>
<.time_ago datetime={stats.last_used_at} />
<% else %>
{gettext("Never used")}
<% end %>
</span>
</div>
</div>
<%= if endpoint.description do %>
<p class="mt-2 text-sm text-base-content/60">{endpoint.description}</p>
<% end %>
</div>
<div class="flex-shrink-0">
<.table_row_menu mode="auto" id={"endpoint-menu-#{endpoint.uuid}"}>
<.table_row_menu_link
navigate={PhoenixKitAI.Routes.ai_path() <> "/endpoints/#{endpoint.uuid}/edit"}
icon="hero-pencil"
label={gettext("Edit")}
/>
<.table_row_menu_button
phx-click="toggle_endpoint"
phx-value-uuid={endpoint.uuid}
phx-disable-with={gettext("Updating…")}
icon={if endpoint.enabled, do: "hero-pause", else: "hero-play"}
label={if endpoint.enabled, do: gettext("Disable"), else: gettext("Enable")}
/>
<.table_row_menu_divider />
<.table_row_menu_button
phx-click="delete_endpoint"
phx-value-uuid={endpoint.uuid}
phx-disable-with={gettext("Deleting…")}
data-confirm={gettext("Are you sure you want to delete this endpoint?")}
icon="hero-trash"
label={gettext("Delete")}
variant="error"
/>
</.table_row_menu>
</div>
</div>
</:card_body>
<%!-- Table view content (inner_block) — md+ only via table_default's data-table-view wrapper --%>
<.table_default_header>
<.table_default_row>
<.table_default_header_cell :if={manual_sort?} class="w-8" />
<.sort_header_cell field={:name} sort={%{by: @sort_by, dir: @sort_dir}} event="sort">
{gettext("Name")}
</.sort_header_cell>
<.sort_header_cell field={:enabled} sort={%{by: @sort_by, dir: @sort_dir}} event="sort">
{gettext("Status")}
</.sort_header_cell>
<.table_default_header_cell>{gettext("Provider")}</.table_default_header_cell>
<.sort_header_cell field={:model} sort={%{by: @sort_by, dir: @sort_dir}} event="sort">
{gettext("Model")}
</.sort_header_cell>
<.sort_header_cell field={:usage} sort={%{by: @sort_by, dir: @sort_dir}} event="sort" align={:right}>
{gettext("Requests")}
</.sort_header_cell>
<.sort_header_cell field={:tokens} sort={%{by: @sort_by, dir: @sort_dir}} event="sort" align={:right}>
{gettext("Tokens")}
</.sort_header_cell>
<.sort_header_cell field={:cost} sort={%{by: @sort_by, dir: @sort_dir}} event="sort" align={:right}>
{gettext("Cost")}
</.sort_header_cell>
<.sort_header_cell field={:last_used} sort={%{by: @sort_by, dir: @sort_dir}} event="sort">
{gettext("Last Used")}
</.sort_header_cell>
<.table_default_header_cell class="w-px whitespace-nowrap text-right">
{gettext("Actions")}
</.table_default_header_cell>
</.table_default_row>
</.table_default_header>
<.table_default_body
id={if manual_sort?, do: "ai-endpoints-tbody"}
phx-hook={if manual_sort?, do: "SortableGrid"}
data-sortable={if manual_sort?, do: "true"}
data-sortable-event={if manual_sort?, do: "reorder_endpoints"}
data-sortable-items={if manual_sort?, do: ".sortable-item"}
data-sortable-handle={if manual_sort?, do: ".pk-drag-handle"}
>
<%= for endpoint <- @endpoints do %>
<% stats =
Map.get(@endpoint_stats, endpoint.uuid, %{
request_count: 0,
total_tokens: 0,
total_cost: 0
}) %>
<% integration = Map.get(@integrations_by_uuid, endpoint.integration_uuid) %>
<% integration_status = integration && integration.data["status"] %>
<.table_default_row
class={
[
if(!endpoint.enabled, do: "opacity-60"),
if(manual_sort?, do: "sortable-item")
]
|> Enum.filter(& &1)
|> Enum.join(" ")
}
data-id={if manual_sort?, do: endpoint.uuid}
>
<.table_default_cell
:if={manual_sort?}
class="pk-drag-handle cursor-grab align-middle text-base-content/40 hover:text-base-content w-8"
title={gettext("Drag to reorder")}
>
<.icon name="hero-bars-3" class="w-4 h-4" />
</.table_default_cell>
<.table_default_cell>
<.link
navigate={PhoenixKitAI.Routes.ai_path() <> "/endpoints/#{endpoint.uuid}/edit"}
class="link link-hover font-medium"
>
{endpoint.name}
</.link>
<%= cond do %>
<% endpoint.integration_uuid && is_nil(integration) -> %>
<span class="badge badge-error badge-xs ml-1" title={gettext("Integration missing")}>!</span>
<% integration_status == "error" -> %>
<span class="badge badge-error badge-xs ml-1" title={gettext("Integration error")}>!</span>
<% integration && integration_status != "connected" -> %>
<span class="badge badge-warning badge-xs ml-1" title={gettext("Not connected")}>!</span>
<% is_nil(endpoint.integration_uuid) -> %>
<span class="badge badge-warning badge-xs ml-1" title={gettext("No integration")}>!</span>
<% true -> %>
<% end %>
</.table_default_cell>
<.table_default_cell>
<.enabled_badge enabled={endpoint.enabled} />
</.table_default_cell>
<.table_default_cell>
<span class="badge badge-outline badge-sm">{endpoint.provider}</span>
</.table_default_cell>
<.table_default_cell class="font-mono text-xs">
{PhoenixKitAI.Endpoint.short_model_name(endpoint.model)}
</.table_default_cell>
<.table_default_cell class="text-right">
{stats.request_count}
</.table_default_cell>
<.table_default_cell class="text-right">
{PhoenixKitAI.Request.format_tokens(stats.total_tokens)}
</.table_default_cell>
<.table_default_cell class="text-right">
{PhoenixKitAI.Request.format_cost(stats.total_cost)}
</.table_default_cell>
<.table_default_cell>
<%= if stats[:last_used_at] do %>
<.time_ago datetime={stats.last_used_at} />
<% else %>
<span class="text-base-content/40">{gettext("Never")}</span>
<% end %>
</.table_default_cell>
<.table_default_cell class="text-right whitespace-nowrap">
<.table_row_menu mode="auto" id={"endpoint-tbl-menu-#{endpoint.uuid}"}>
<.table_row_menu_link
navigate={PhoenixKitAI.Routes.ai_path() <> "/endpoints/#{endpoint.uuid}/edit"}
icon="hero-pencil"
label={gettext("Edit")}
/>
<.table_row_menu_button
phx-click="toggle_endpoint"
phx-value-uuid={endpoint.uuid}
phx-disable-with={gettext("Updating…")}
icon={if endpoint.enabled, do: "hero-pause", else: "hero-play"}
label={if endpoint.enabled, do: gettext("Disable"), else: gettext("Enable")}
/>
<.table_row_menu_divider />
<.table_row_menu_button
phx-click="delete_endpoint"
phx-value-uuid={endpoint.uuid}
phx-disable-with={gettext("Deleting…")}
data-confirm={gettext("Are you sure you want to delete this endpoint?")}
icon="hero-trash"
label={gettext("Delete")}
variant="error"
/>
</.table_row_menu>
</.table_default_cell>
</.table_default_row>
<% end %>
</.table_default_body>
</.table_default>
<%!-- Pagination --%>
<% total_pages = ceil(@total_endpoints / @page_size) %>
<%= if total_pages > 1 do %>
<div class="flex justify-center p-4 border-t border-base-300">
<div class="join">
<%= for page_num <- 1..total_pages do %>
<button
class={"join-item btn btn-sm #{if page_num == @page, do: "btn-active", else: ""}"}
phx-click="goto_page"
phx-value-page={page_num}
>
{page_num}
</button>
<% end %>
</div>
</div>
<% end %>
<% else %>
<.empty_state
variant="featured"
icon="hero-cpu-chip"
title={gettext("No Endpoints Yet")}
description={gettext("Create your first AI endpoint to get started.")}
>
<.link
navigate={PhoenixKitAI.Routes.ai_path() <> "/endpoints/new"}
class="btn btn-primary btn-lg"
>
<.icon name="hero-plus" class="w-5 h-5 mr-2" /> {gettext("Create First Endpoint")}
</.link>
</.empty_state>
<% end %>
<% end %>
<%= if @active_tab == "usage" do %>
<%!-- Usage Statistics --%>
<%= if @usage_loaded do %>
<%!-- Usage Stats --%>
<div class="card bg-base-200 mb-6">
<div class="card-body py-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<%!-- Today --%>
<div class="space-y-3">
<div>
<div class="text-sm text-base-content/70">{gettext("Requests / Tokens today")}</div>
<div class="text-xl font-semibold">
{@usage_stats.today.total_requests} / {PhoenixKitAI.Request.format_tokens(
@usage_stats.today.total_tokens
)}
</div>
</div>
<div>
<div class="text-sm text-base-content/70">{gettext("Successful / Errors today")}</div>
<div class="text-xl font-semibold">
<span class="text-success">{@usage_stats.today.success_count}</span>
<span class="text-base-content/50"> / </span>
<span class="text-error">{@usage_stats.today.error_count}</span>
</div>
</div>
<div>
<div class="text-sm text-base-content/70">{gettext("Avg Latency today")}</div>
<div class="text-xl font-semibold">
{PhoenixKitAI.Request.format_latency(
@usage_stats.today.avg_latency_ms
)}
</div>
</div>
</div>
<%!-- 30 Days --%>
<div class="space-y-3">
<div>
<div class="text-sm text-base-content/70">{gettext("Requests / Tokens (30 days)")}</div>
<div class="text-xl font-semibold">
{@usage_stats.last_30_days.total_requests} / {PhoenixKitAI.Request.format_tokens(
@usage_stats.last_30_days.total_tokens
)}
</div>
</div>
<div>
<div class="text-sm text-base-content/70">{gettext("Successful / Errors (30 days)")}</div>
<div class="text-xl font-semibold">
<span class="text-success">{@usage_stats.last_30_days.success_count}</span>
<span class="text-base-content/50"> / </span>
<span class="text-error">{@usage_stats.last_30_days.error_count}</span>
</div>
</div>
<div>
<div class="text-sm text-base-content/70">{gettext("Avg Latency (30 days)")}</div>
<div class="text-xl font-semibold">
{PhoenixKitAI.Request.format_latency(
@usage_stats.last_30_days.avg_latency_ms
)}
</div>
</div>
</div>
<%!-- All Time --%>
<div class="space-y-3">
<div>
<div class="text-sm text-base-content/70">{gettext("Requests / Tokens (all time)")}</div>
<div class="text-xl font-semibold">
{@usage_stats.all_time.total_requests} / {PhoenixKitAI.Request.format_tokens(
@usage_stats.all_time.total_tokens
)}
</div>
</div>
<div>
<div class="text-sm text-base-content/70">{gettext("Successful / Errors (all time)")}</div>
<div class="text-xl font-semibold">
<span class="text-success">{@usage_stats.all_time.success_count}</span>
<span class="text-base-content/50"> / </span>
<span class="text-error">{@usage_stats.all_time.error_count}</span>
</div>
</div>
<div>
<div class="text-sm text-base-content/70">{gettext("Avg Latency (all time)")}</div>
<div class="text-xl font-semibold">
{PhoenixKitAI.Request.format_latency(
@usage_stats.all_time.avg_latency_ms
)}
</div>
</div>
</div>
</div>
</div>
</div>
<%!-- Recent Requests --%>
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg mb-4">{gettext("Recent Requests")}</h3>
<%!-- Filters --%>
<form phx-change="usage_filter" class="flex flex-wrap items-center gap-2 mb-4">
<%!-- Date filter (always visible) --%>
<label class="select select-sm">
<select name="date">
<option value="today" selected={@usage_filter_date == "today"}>{gettext("Today")}</option>
<option value="7d" selected={@usage_filter_date == "7d"}>{gettext("Last 7 Days")}</option>
<option value="30d" selected={@usage_filter_date == "30d"}>{gettext("Last 30 Days")}</option>
<option value="all" selected={@usage_filter_date == "all"}>{gettext("All Time")}</option>
</select>
</label>
<%!-- Other filters (only show dropdowns with 2+ options) --%>
<%= if length(@usage_filter_options.endpoints) > 1 do %>
<label class="select select-sm">
<select name="endpoint">
<option value="">{gettext("All Endpoints")}</option>
<%= for {id, name} <- @usage_filter_options.endpoints do %>
<option value={id} selected={@usage_filter_endpoint == id}>{name}</option>
<% end %>
</select>
</label>
<% end %>
<%= if length(@usage_filter_options.models) > 1 do %>
<label class="select select-sm">
<select name="model">
<option value="">{gettext("All Models")}</option>
<%= for model <- @usage_filter_options.models do %>
<option value={model} selected={@usage_filter_model == model}>
{PhoenixKitAI.Request.short_model_name(model)}
</option>
<% end %>
</select>
</label>
<% end %>
<%= if length(@usage_filter_options.statuses) > 1 do %>
<label class="select select-sm">
<select name="status">
<option value="">{gettext("All Statuses")}</option>
<%= for status <- @usage_filter_options.statuses do %>
<option value={status} selected={@usage_filter_status == status}>
{PhoenixKitAI.Request.status_label(status)}
</option>
<% end %>
</select>
</label>
<% end %>
<%= if length(@usage_filter_options.sources) > 1 do %>
<label class="select select-sm">
<select name="source">
<option value="">{gettext("All Sources")}</option>
<%= for source <- @usage_filter_options.sources do %>
<option value={source} selected={@usage_filter_source == source}>
{source}
</option>
<% end %>
</select>
</label>
<% end %>
<%= if @usage_filter_endpoint || @usage_filter_model || @usage_filter_status || @usage_filter_source || @usage_filter_date != "7d" do %>
<button
type="button"
phx-click="clear_usage_filters"
class="btn btn-ghost btn-sm"
>
<.icon name="hero-x-mark" class="w-4 h-4" /> {gettext("Clear")}
</button>
<% end %>
</form>
<%!-- Results count --%>
<div class="text-sm text-base-content/70 mb-2">
{gettext("Showing %{shown} of %{total} requests",
shown: length(@usage_requests),
total: @usage_total_requests
)}
</div>
<%= if Enum.empty?(@usage_requests) do %>
<.empty_state
icon="hero-magnifying-glass"
title={gettext("No requests match the current filters")}
/>
<% else %>
<%!-- Columns tier by importance. xl-only columns
(Source/Model/Prompt) wait because the admin sidebar
reclaims 256px from lg+, dropping the content area
back to ~768px — there's no room for them until xl.
Model is xl rather than always-on because the
Endpoint already identifies the configuration. --%>
<% sort = %{by: @usage_sort_by, dir: @usage_sort_dir} %>
<.table_default size="sm">
<.table_default_header>
<.table_default_row>
<.sort_header_cell field={:inserted_at} sort={sort} event="usage_sort">
{gettext("Time")}
</.sort_header_cell>
<.sort_header_cell field={:endpoint_name} sort={sort} event="usage_sort">
{gettext("Endpoint")}
</.sort_header_cell>
<.table_default_header_cell class="hidden xl:table-cell">
{gettext("Prompt")}
</.table_default_header_cell>
<.sort_header_cell
field={:model}
sort={sort}
event="usage_sort"
class="hidden xl:table-cell"
>
{gettext("Model")}
</.sort_header_cell>
<.sort_header_cell
field={:total_tokens}
sort={sort}
event="usage_sort"
class="hidden md:table-cell"
>
{gettext("Tokens")}
</.sort_header_cell>
<.sort_header_cell
field={:latency_ms}
sort={sort}
event="usage_sort"
class="hidden md:table-cell"
>
{gettext("Latency")}
</.sort_header_cell>
<.sort_header_cell
field={:cost_cents}
sort={sort}
event="usage_sort"
class="hidden sm:table-cell"
>
{gettext("Price")}
</.sort_header_cell>
<.sort_header_cell field={:status} sort={sort} event="usage_sort">
{gettext("Status")}
</.sort_header_cell>
<.table_default_header_cell class="hidden xl:table-cell">
{gettext("Source")}
</.table_default_header_cell>
<.table_default_header_cell class="w-px whitespace-nowrap text-right">
{gettext("Actions")}
</.table_default_header_cell>
</.table_default_row>
</.table_default_header>
<.table_default_body>
<%= for request <- @usage_requests do %>
<.table_default_row>
<.table_default_cell class="text-sm">
<.time_ago datetime={request.inserted_at} />
</.table_default_cell>
<.table_default_cell class="text-sm">
{request.endpoint_name || "-"}
</.table_default_cell>
<.table_default_cell class="hidden xl:table-cell text-sm">
<%= if request.prompt do %>
<.link
navigate={PhoenixKitAI.Routes.ai_path() <> "/prompts/#{request.prompt.uuid}/edit"}
class="link link-hover link-primary"
>
{request.prompt_name}
</.link>
<% else %>
<span class="text-base-content/50">{request.prompt_name || "-"}</span>
<% end %>
</.table_default_cell>
<.table_default_cell class="hidden xl:table-cell text-sm font-mono">
{PhoenixKitAI.Request.short_model_name(request.model)}
</.table_default_cell>
<.table_default_cell class="hidden md:table-cell text-sm">
{PhoenixKitAI.Request.format_tokens(request.total_tokens)}
</.table_default_cell>
<.table_default_cell class="hidden md:table-cell text-sm">
{PhoenixKitAI.Request.format_latency(request.latency_ms)}
</.table_default_cell>
<.table_default_cell class="hidden sm:table-cell text-sm">
{PhoenixKitAI.Request.format_cost(request.cost_cents)}
</.table_default_cell>
<.table_default_cell>
<span class={"badge badge-sm #{PhoenixKitAI.Request.status_color(request.status)}"}>
{PhoenixKitAI.Request.status_label(request.status)}
</span>
<%= if request.error_message do %>
<span title={request.error_message} class="inline-block align-middle">
<.icon
name="hero-information-circle"
class="w-4 h-4 text-error ml-1"
/>
</span>
<% end %>
</.table_default_cell>
<.table_default_cell
class="hidden xl:table-cell text-sm font-mono text-base-content/70 max-w-[150px] truncate"
title={request.metadata["source"]}
>
{request.metadata["source"] || "-"}
</.table_default_cell>
<.table_default_cell class="text-right whitespace-nowrap">
<button
phx-click="show_request_details"
phx-value-uuid={request.uuid}
title={gettext("View")}
class="btn btn-xs btn-outline btn-info"
>
<.icon name="hero-eye" class="w-4 h-4" />
<span class="sr-only">{gettext("View")}</span>
</button>
</.table_default_cell>
</.table_default_row>
<% end %>
</.table_default_body>
</.table_default>
<%= if length(@usage_requests) < @usage_total_requests do %>
<div class="mt-4 text-center">
<button phx-click="load_more_requests" class="btn btn-outline btn-sm">
{gettext("Load More")}
</button>
</div>
<% end %>
<% end %>
</div>
</div>
<% else %>
<div class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
<% end %>
<% end %>
</div>
<%!-- Request Details Modal --%>
<%= if @selected_request do %>
<div class="modal modal-open">
<div class="modal-box max-w-3xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">{gettext("Request Details")}</h3>
<button phx-click="close_request_details" class="btn btn-sm btn-circle btn-ghost">
✕
</button>
</div>
<%!-- Request Summary --%>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div>
<div class="text-xs text-base-content/70">ID</div>
<div class="font-mono text-sm">{@selected_request.uuid}</div>
</div>
<div>
<div class="text-xs text-base-content/70">Status</div>
<span class={"badge badge-sm #{PhoenixKitAI.Request.status_color(@selected_request.status)}"}>
{PhoenixKitAI.Request.status_label(@selected_request.status)}
</span>
</div>
<div>
<div class="text-xs text-base-content/70">Latency</div>
<div class="text-sm">
{PhoenixKitAI.Request.format_latency(@selected_request.latency_ms)}
</div>
</div>
<div>
<div class="text-xs text-base-content/70">Cost</div>
<div class="text-sm">
{PhoenixKitAI.Request.format_cost(@selected_request.cost_cents)}
</div>
</div>
</div>
<%!-- Model, Endpoint & Prompt Info --%>
<div class="grid grid-cols-3 gap-4 mb-4">
<div>
<div class="text-xs text-base-content/70">Endpoint</div>
<div class="text-sm">{@selected_request.endpoint_name || "-"}</div>
</div>
<div>
<div class="text-xs text-base-content/70">Prompt</div>
<div class="text-sm">
<%= if @selected_request.prompt && @selected_request.prompt_name do %>
<.link
navigate={PhoenixKitAI.Routes.ai_path() <> "/prompts/#{@selected_request.prompt.uuid}/edit"}
class="link link-hover link-primary"
>
{@selected_request.prompt_name}
</.link>
<% else %>
<span class="text-base-content/50">{@selected_request.prompt_name || "-"}</span>
<% end %>
</div>
</div>
<div>
<div class="text-xs text-base-content/70">Model</div>
<div class="text-sm font-mono">{@selected_request.model}</div>
</div>
</div>
<%!-- Token Usage --%>
<div class="grid grid-cols-3 gap-4 mb-4">
<div>
<div class="text-xs text-base-content/70">Input Tokens</div>
<div class="text-sm">
{PhoenixKitAI.Request.format_tokens(@selected_request.input_tokens)}
</div>
</div>
<div>
<div class="text-xs text-base-content/70">Output Tokens</div>
<div class="text-sm">
{PhoenixKitAI.Request.format_tokens(@selected_request.output_tokens)}
</div>
</div>
<div>
<div class="text-xs text-base-content/70">Total Tokens</div>
<div class="text-sm font-semibold">
{PhoenixKitAI.Request.format_tokens(@selected_request.total_tokens)}
</div>
</div>
</div>
<%!-- Timestamp --%>
<div class="mb-4">
<div class="text-xs text-base-content/70">Timestamp</div>
<div class="text-sm font-mono">
{Calendar.strftime(@selected_request.inserted_at, "%Y-%m-%d %H:%M:%S UTC")}
</div>
</div>
<%!-- Error Message (if any) --%>
<%= if @selected_request.error_message do %>
<div class="mb-4">
<div class="text-xs text-base-content/70">Error Message</div>
<div class="bg-error/10 text-error p-2 rounded text-sm font-mono">
{@selected_request.error_message}
</div>
</div>
<% end %>
<%!-- Messages (Request) --%>
<%= if @selected_request.metadata["messages"] do %>
<div class="mb-4">
<div class="text-xs text-base-content/70 mb-1">Request Messages</div>
<div class="space-y-2">
<%= for msg <- @selected_request.metadata["messages"] do %>
<div class={"p-3 rounded #{if msg["role"] == "user", do: "bg-primary/10 border-l-2 border-primary", else: "bg-base-300 border-l-2 border-base-content/30"}"}>
<div class="text-xs font-semibold text-base-content/70 mb-1 uppercase">
{msg["role"]}
</div>
<div class="text-sm whitespace-pre-wrap">{msg["content"]}</div>
</div>
<% end %>
</div>
</div>
<% end %>
<%!-- Response --%>
<%= if @selected_request.metadata["response"] do %>
<div class="mb-4">
<div class="text-xs text-base-content/70 mb-1">{gettext("Response")}</div>
<div class="bg-success/10 border-l-2 border-success p-3 rounded">
<div class="text-xs font-semibold text-base-content/70 mb-1 uppercase">
{gettext("assistant")}
</div>
<div class="text-sm whitespace-pre-wrap">
{@selected_request.metadata["response"]}
</div>
</div>
</div>
<% end %>
<%!-- Reasoning / chain-of-thought (reasoning models only).
Collapsed by default since the reasoning can dwarf the
actual answer and would otherwise dominate the panel.
Source field varies by provider — see Completion.extract_reasoning/1. --%>
<%= if @selected_request.metadata["response_reasoning"] do %>
<details class="mb-4">
<summary class="text-xs text-base-content/70 mb-1 cursor-pointer hover:text-base-content">
{gettext("Reasoning")} ({String.length(@selected_request.metadata["response_reasoning"])} chars)
</summary>
<div class="bg-info/10 border-l-2 border-info p-3 rounded mt-1">
<div class="text-xs font-semibold text-base-content/70 mb-1 uppercase">
{gettext("chain of thought")}
</div>
<div class="text-sm whitespace-pre-wrap font-mono">
{@selected_request.metadata["response_reasoning"]}
</div>
</div>
</details>
<% end %>
<%!-- Parameters Used --%>
<% params_to_show =
Map.take(@selected_request.metadata || %{}, ["temperature", "max_tokens"]) %>
<%= if params_to_show != %{} do %>
<div class="mb-4">
<div class="text-xs text-base-content/70 mb-1">Parameters</div>
<div class="flex flex-wrap gap-2">
<%= for {key, value} <- params_to_show, not is_nil(value) do %>
<span class="badge badge-outline badge-sm h-auto">{key}: {value}</span>
<% end %>
</div>
</div>
<% end %>
<%!-- Source (caller module/function) --%>
<%= if @selected_request.metadata["source"] do %>
<div class="mb-4">
<div class="text-xs text-base-content/70">Source</div>
<div class="text-sm font-mono bg-base-200 px-2 py-1 rounded inline-block">
{@selected_request.metadata["source"]}
</div>
</div>
<% end %>
<%!-- Caller Context (request ID, node, memory, pid) --%>
<%= if ctx = @selected_request.metadata["caller_context"] do %>
<div class="mb-4">
<div class="text-xs text-base-content/70 mb-1">Caller Context</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
<%= if ctx["request_id"] do %>
<div>
<span class="text-base-content/70">Request ID:</span>
<span class="font-mono text-xs">{ctx["request_id"]}</span>
</div>
<% end %>
<div>
<span class="text-base-content/70">Node:</span>
<span class="font-mono text-xs">{ctx["node"]}</span>
</div>
<div>
<span class="text-base-content/70">PID:</span>
<span class="font-mono text-xs">{ctx["pid"]}</span>
</div>
<%= if ctx["memory_bytes"] do %>
<div>
<span class="text-base-content/70">Memory:</span>
<span>{format_bytes(ctx["memory_bytes"])}</span>
</div>
<% end %>
</div>
</div>
<% end %>
<%!-- Stacktrace (collapsible) --%>
<%= if @selected_request.metadata["stacktrace"] && length(@selected_request.metadata["stacktrace"]) > 0 do %>
<div class="collapse collapse-arrow bg-base-200 mb-2">
<input
type="checkbox"
name="stacktrace-toggle"
phx-update="ignore"
id="stacktrace-toggle"
/>
<div class="collapse-title text-sm font-medium">
<.icon name="hero-queue-list" class="w-4 h-4 inline mr-1" />
Stacktrace ({length(@selected_request.metadata["stacktrace"])} frames)
</div>
<div class="collapse-content">
<div class="bg-base-300 p-3 rounded overflow-x-auto">
<pre class="text-xs font-mono whitespace-pre-wrap">{Enum.join(@selected_request.metadata["stacktrace"], "\n")}</pre>
</div>
</div>
</div>
<% end %>
<%!-- Request Payload (what we sent) --%>
<%= if @selected_request.metadata["request_payload"] do %>
<div class="collapse collapse-arrow bg-base-200 mb-2">
<input
type="checkbox"
name="request-payload-toggle"
phx-update="ignore"
id="request-payload-toggle"
/>
<div class="collapse-title text-sm font-medium">
<.icon name="hero-arrow-up-on-square" class="w-4 h-4 inline mr-1" />
{gettext("Request Payload (sent to API)")}
</div>
<div class="collapse-content">
<div class="bg-base-300 p-3 rounded overflow-x-auto">
<pre class="text-xs font-mono whitespace-pre-wrap"><code>{Jason.encode!(@selected_request.metadata["request_payload"], pretty: true)}</code></pre>
</div>
</div>
</div>
<% end %>
<%!-- Raw API Response --%>
<%= if @selected_request.metadata["raw_response"] do %>
<div class="collapse collapse-arrow bg-base-200 mb-2">
<input
type="checkbox"
name="response-json-toggle"
phx-update="ignore"
id="response-json-toggle"
/>
<div class="collapse-title text-sm font-medium">
<.icon name="hero-arrow-down-on-square" class="w-4 h-4 inline mr-1" />
{gettext("API Response (raw)")}
</div>
<div class="collapse-content">
<div class="bg-base-300 p-3 rounded overflow-x-auto">
<pre class="text-xs font-mono whitespace-pre-wrap"><code>{Jason.encode!(@selected_request.metadata["raw_response"], pretty: true)}</code></pre>
</div>
</div>
</div>
<% end %>
<%!-- Database Record --%>
<div class="collapse collapse-arrow bg-base-200">
<input
type="checkbox"
name="db-record-toggle"
phx-update="ignore"
id="db-record-toggle"
/>
<div class="collapse-title text-sm font-medium">
<.icon name="hero-circle-stack" class="w-4 h-4 inline mr-1" /> {gettext("Database Record")}
</div>
<div class="collapse-content">
<div class="bg-base-300 p-3 rounded overflow-x-auto">
<pre class="text-xs font-mono whitespace-pre-wrap"><code>{Jason.encode!(Map.drop(@selected_request, [:metadata]) |> Map.put(:metadata, "...truncated..."), pretty: true)}</code></pre>
</div>
</div>
</div>
<div class="modal-action">
<button phx-click="close_request_details" class="btn">Close</button>
</div>
</div>
<div class="modal-backdrop" phx-click="close_request_details"></div>
</div>
<% end %>