lib/openagents_web/live/admin_recordings_live.ex

main at 436d37facc96 · 7 KB

defmodule OpenAgentsWeb.AdminRecordingsLive do
  @moduledoc """
  Lists bounded voice-call metadata and operator-only recording playback.

  The account panel remains the primary operator surface. This focused page
  restores recording qualification without exposing transcripts, prompts,
  provider identifiers, or recording bytes to ordinary accounts.
  """

  use OpenAgentsWeb, :live_view

  alias OpenAgents.Accounts
  alias OpenAgents.Admin
  alias OpenAgents.Admin.Call
  alias OpenAgents.Voice.Recordings

  @page_size 25

  @impl true
  def mount(_params, _session, socket) do
    if Accounts.admin?(socket.assigns.current_user) do
      {:ok,
       socket
       |> assign(:page_title, "Voice recordings · OpenAgents")
       |> assign(:offset, 0)
       |> assign(:page_size, @page_size)
       |> assign(:recording_config, Recordings.config())
       |> stream_configure(:calls, dom_id: &"admin-call-#{&1.session_id}")
       |> load_page()}
    else
      {:ok, redirect(socket, to: ~p"/")}
    end
  end

  @impl true
  def handle_event(event, _params, socket) when event in ["next_page", "previous_page"] do
    if Accounts.admin?(socket.assigns.current_user) do
      paginate(event, socket)
    else
      {:noreply, redirect(socket, to: ~p"/")}
    end
  end

  defp paginate("next_page", socket) do
    offset = socket.assigns.offset + @page_size

    if offset < socket.assigns.totals.calls do
      {:noreply, socket |> assign(:offset, offset) |> load_page()}
    else
      {:noreply, socket}
    end
  end

  defp paginate("previous_page", socket) do
    offset = max(socket.assigns.offset - @page_size, 0)
    {:noreply, socket |> assign(:offset, offset) |> load_page()}
  end

  defp load_page(socket) do
    calls = Admin.list_calls(limit: @page_size, offset: socket.assigns.offset)

    socket
    |> assign(:totals, Admin.recording_totals())
    |> assign(:calls_empty?, calls == [])
    |> assign(:page_count, length(calls))
    |> stream(:calls, calls, reset: true)
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
      title="Voice recordings"
      wide
    >
      <section id="admin-recordings-page" class="panel" aria-label="Voice call recordings">
        <header class="panel__header">
          <div>
            <h1 class="panel__title">Voice recordings</h1>
            <p class="text-muted-foreground">
              Audio is stored {sealed_label(@recording_config)} and deleted {@recording_config.retention_days} days after a call ends.
            </p>
          </div>
          <div class="admin-totals">
            <.badge variant={:dim}>{@totals.calls} calls</.badge>
            <.badge variant={:info}>{@totals.recorded} with audio</.badge>
            <.badge variant={:dim}>{format_bytes(@totals.byte_size)} stored</.badge>
          </div>
        </header>

        <div id="admin-recording-calls" phx-update="stream" class="admin-rows">
          <.empty
            :if={@calls_empty?}
            id="admin-recordings-empty"
            title="No voice calls yet"
          >
            Calls appear here after an account uses voice.
          </.empty>

          <.card :for={{dom_id, call} <- @streams.calls} id={dom_id}>
            <div class="admin-row">
              <div class="admin-identity">
                <.avatar
                  src={call.github_avatar_url}
                  alt=""
                  size={:sm}
                  fallback={String.first(call.github_login)}
                />
                <span>
                  <strong :if={Call.display_name(call)}>{Call.display_name(call)}</strong>
                  <span>@{call.github_login}</span>
                </span>
              </div>

              <div class="admin-state">
                <.badge variant={status_variant(call.status)}>{String.upcase(call.status)}</.badge>
                <.badge :if={call.termination_reason} variant={:dim}>
                  {call.termination_reason}
                </.badge>
                <.badge :if={call.failure_code} variant={:danger}>{call.failure_code}</.badge>
              </div>

              <dl class="admin-meta">
                <div>
                  <dt>Started</dt><dd>{format_timestamp(call.started_at)}</dd>
                </div>
                <div>
                  <dt>Call length</dt><dd>{format_call_length(call)}</dd>
                </div>
                <div>
                  <dt>Model</dt><dd>{call.model_id}</dd>
                </div>
                <div>
                  <dt>Tokens</dt><dd>{format_count(call.total_tokens)}</dd>
                </div>
                <div>
                  <dt>Transcript</dt><dd>{format_count(call.transcript_item_count)} items</dd>
                </div>
                <div :if={call.recording}>
                  <dt>Audio</dt>
                  <dd>{format_bytes(call.recording.byte_size)} · {Call.completeness(call)}</dd>
                </div>
              </dl>

              <.audio_player
                :if={Call.playable?(call)}
                id={"admin-audio-#{call.session_id}"}
                src={~p"/admin/recordings/#{call.recording.id}/audio"}
                label={"Call with @#{call.github_login} on #{format_timestamp(call.started_at)}"}
              />

              <p :if={!Call.playable?(call)} class="admin-absence">{Call.absence_reason(call)}</p>
            </div>
          </.card>
        </div>

        <nav :if={@totals.calls > @page_size} class="admin-pager" aria-label="Call pages">
          <.button
            id="admin-recordings-previous"
            variant={:secondary}
            size={:sm}
            disabled={@offset == 0}
            phx-click="previous_page"
          >
            Previous
          </.button>
          <span>{@offset + 1}–{@offset + @page_count} of {@totals.calls}</span>
          <.button
            id="admin-recordings-next"
            variant={:secondary}
            size={:sm}
            disabled={@offset + @page_size >= @totals.calls}
            phx-click="next_page"
          >
            Next
          </.button>
        </nav>
      </section>
    </Layouts.app>
    """
  end

  defp sealed_label(%{sealed?: true}), do: "encrypted"
  defp sealed_label(%{sealed?: false}), do: "unencrypted"

  defp status_variant("ended"), do: :dim
  defp status_variant("failed"), do: :danger
  defp status_variant(_active), do: :success

  defp format_call_length(%Call{recording: %{client_duration_ms: ms}}) when is_integer(ms),
    do: format_duration_ms(ms)

  defp format_call_length(%Call{started_at: started, ended_at: ended})
       when not is_nil(started) and not is_nil(ended),
       do: started |> DateTime.diff(ended, :millisecond) |> abs() |> format_duration_ms()

  defp format_call_length(%Call{}), do: "—"

  defp format_duration_ms(ms) do
    total_seconds = div(ms, 1_000)

    "#{div(total_seconds, 60)}:#{total_seconds |> rem(60) |> Integer.to_string() |> String.pad_leading(2, "0")}"
  end

  defp format_timestamp(nil), do: "—"

  defp format_timestamp(%DateTime{} = at) do
    at |> DateTime.truncate(:second) |> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
  end

  defp format_bytes(bytes) when is_integer(bytes) and bytes < 1_024, do: "#{bytes} B"

  defp format_bytes(bytes) when is_integer(bytes) and bytes < 1_048_576,
    do: "#{Float.round(bytes / 1_024, 1)} KB"

  defp format_bytes(bytes) when is_integer(bytes),
    do: "#{Float.round(bytes / 1_048_576, 1)} MB"

  defp format_bytes(_bytes), do: "0 B"

  defp format_count(count) when is_integer(count) do
    count
    |> Integer.to_string()
    |> String.reverse()
    |> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
    |> String.reverse()
  end
end