Skip to main content

lib/oban/web/live/jobs/detail_component.ex

defmodule Oban.Web.Jobs.DetailComponent do
  use Oban.Web, :live_component

  import Oban.Web.FormComponents

  alias Oban.Web.Jobs.{HistoryChartComponent, TimelineComponent}
  alias Oban.Web.{Resolver, Timing}

  @impl Phoenix.LiveComponent
  def update(assigns, socket) do
    auto_open_diagnostics? =
      assigns[:diagnostics] != nil and
        socket.assigns[:diagnostics] == nil and
        is_struct(assigns[:job]) and assigns.job.state == "executing"

    socket =
      socket
      |> assign(assigns)
      |> assign_new(:error_index, fn -> 0 end)
      |> assign_new(:error_sort, fn -> :desc end)
      |> assign_new(:edit_changed?, fn -> false end)
      |> assign_new(:queues, fn -> [] end)
      |> assign_new(:diagnostics_open?, fn -> false end)
      |> assign_new(:form, fn -> form_from_job(assigns.job) end)
      |> then(fn socket ->
        if auto_open_diagnostics?, do: assign(socket, :diagnostics_open?, true), else: socket
      end)

    {:ok, socket}
  end

  defp form_from_job(job) do
    %{
      worker: job.worker,
      queue: job.queue,
      priority: job.priority,
      max_attempts: job.max_attempts,
      scheduled_at: format_datetime(job.scheduled_at),
      tags: format_job_tags(job.tags),
      args: format_job_args(job.args)
    }
  end

  @impl Phoenix.LiveComponent
  def render(assigns) do
    ~H"""
    <div id="job-details">
      <div class="flex justify-between items-center px-3 py-4 border-b border-gray-200 dark:border-gray-700">
        <button
          id="back-link"
          class="flex items-center hover:text-blue-500 cursor-pointer bg-transparent border-0 p-0"
          data-escape-back={true}
          data-title="Back to jobs"
          phx-hook="HistoryBack"
          type="button"
        >
          <Icons.icon name="icon-arrow-left" class="w-5 h-5" />
          <span class="text-lg font-bold ml-2">{job_title(@job)}</span>
        </button>

        <div class="flex space-x-3">
          <Core.status_badge :if={@job.meta["batch"]} icon="square_2x2" label="Batch" />
          <Core.status_badge :if={@job.meta["workflow"]} icon="rectangle_group" label="Workflow" />
          <Core.status_badge :if={@job.meta["chunk"]} icon="user_group" label="Chunk" />
          <Core.status_badge :if={@job.meta["chain"]} icon="link" label="Chain" />
          <Core.status_badge :if={@job.meta["recorded"]} icon="camera" label="Recorded" />
          <Core.status_badge :if={signal_status(@job) != :none} icon="signal" label="Signal" />
          <Core.status_badge :if={@job.meta["encrypted"]} icon="lock_closed" label="Encrypted" />
          <Core.status_badge :if={@job.meta["structured"]} icon="table_cells" label="Structured" />
          <Core.status_badge :if={@job.meta["decorated"]} icon="sparkles" label="Decorated" />
          <Core.status_badge :if={@job.meta["rescued"]} icon="life_buoy" label="Rescued" />

          <Core.icon_button
            id="detail-cancel"
            icon="x_circle"
            label="Cancel"
            color="yellow"
            tooltip="Cancel this job"
            disabled={not cancelable?(@job)}
            phx-target={@myself}
            phx-click="cancel"
          />

          <Core.icon_button
            id="detail-retry"
            icon="arrow_path"
            label="Retry"
            color="blue"
            tooltip="Retry this job"
            disabled={not (runnable?(@job) or retryable?(@job))}
            phx-target={@myself}
            phx-click="retry"
          />

          <Core.icon_button
            id="detail-delete"
            icon="trash"
            label="Delete"
            color="red"
            tooltip="Delete this job"
            disabled={not deletable?(@job)}
            confirm="Are you sure you want to delete this job?"
            phx-target={@myself}
            phx-click="delete"
          />

          <Core.icon_button
            id="detail-edit"
            icon="pencil_square"
            label="Edit"
            color="violet"
            tooltip="Edit this job"
            disabled={executing?(@job)}
            phx-click={scroll_to_edit()}
          />
        </div>
      </div>

      <div class="grid grid-cols-1 lg:grid-cols-5 gap-6 px-3 pt-6">
        <div class="lg:col-span-3">
          <TimelineComponent.render job={@job} os_time={@os_time} />
        </div>

        <div class="lg:col-span-2">
          <div class="grid grid-cols-3 gap-4 mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
            <div class="flex flex-col">
              <span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
                ID
              </span>
              <span class="text-base text-gray-800 dark:text-gray-200 tabular">
                {@job.id}
              </span>
            </div>

            <div class="flex flex-col">
              <span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
                Wait Time
              </span>
              <span class="text-base text-gray-800 dark:text-gray-200">
                {Timing.queue_time(@job)}
              </span>
            </div>

            <div class="flex flex-col">
              <span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
                Exec Time
              </span>
              <span class="text-base text-gray-800 dark:text-gray-200">
                {Timing.run_time(@job)}
              </span>
            </div>

            <div class="flex flex-col">
              <span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
                Attempted By
              </span>
              <span class="text-base text-gray-800 dark:text-gray-200">
                {attempted_by(@job)}
              </span>
            </div>

            <div class="flex flex-col">
              <span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
                Snoozed
              </span>
              <span class="text-base text-gray-800 dark:text-gray-200">
                {@job.meta["snoozed"] || "—"}
              </span>
            </div>

            <div class="flex flex-col">
              <span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
                Rescued
              </span>
              <span class="text-base text-gray-800 dark:text-gray-200">
                {@job.meta["rescued"] || "—"}
              </span>
            </div>
          </div>

          <div class="grid grid-cols-3 gap-4 mb-4 px-3">
            <.link
              id="queue-link"
              patch={oban_path([:queues, @job.queue])}
              class="flex flex-col -m-2 p-2 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
              data-title="View queue details"
              phx-hook="Tippy"
            >
              <span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
                Queue
              </span>
              <span class="text-base text-gray-800 dark:text-gray-200">
                {@job.queue}
              </span>
            </.link>

            <div class="flex flex-col">
              <span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
                Attempt
              </span>
              <span class="text-base text-gray-800 dark:text-gray-200">
                {@job.attempt} of {@job.max_attempts}
              </span>
            </div>

            <div class="flex flex-col">
              <span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
                Priority
              </span>
              <span class="text-base text-gray-800 dark:text-gray-200">
                {@job.priority}
              </span>
            </div>

            <.link
              :if={@job.meta["workflow_id"]}
              id="workflow-link"
              navigate={oban_path([:workflows, @job.meta["workflow_id"]])}
              class="flex flex-col col-span-3 -m-2 p-2 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
              data-title="View workflow"
              phx-hook="Tippy"
            >
              <span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
                Workflow
              </span>
              <span class="text-base text-gray-800 dark:text-gray-200 flex items-center">
                <Icons.icon name="icon-rectangle-group" class="w-4 h-4 mr-1.5 text-violet-500" />
                <span class="truncate">{workflow_display_name(@job)}</span>
              </span>
            </.link>
          </div>
        </div>
      </div>

      <.job_data_section job={@job} resolver={@resolver} />

      <div class="px-3 py-6 border-t border-gray-200 dark:border-gray-700">
        <button
          id="diagnostics-toggle"
          type="button"
          class="flex items-center w-full space-x-2 px-2 py-1.5 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
          phx-click="toggle-diagnostics"
          phx-target={@myself}
        >
          <Icons.icon
            name="icon-chevron-right"
            id="diagnostics-chevron"
            class={["w-5 h-5 transition-transform", if(@diagnostics_open?, do: "rotate-90")]}
          />
          <span class="font-semibold">Diagnostics</span>
          <.pro_badge id="diagnostics-badge" tooltip="Diagnostics for executing Oban.Pro.Worker jobs" />
          <.stale_badge :if={@diagnostics && not executing?(@job)} />
        </button>

        <div :if={@diagnostics_open?} id="diagnostics-content" class="mt-3">
          <%= if @diagnostics do %>
            <div class="grid grid-cols-2 gap-4">
              <div class="bg-gray-50 dark:bg-gray-800 rounded-md p-4">
                <div class="flex justify-between items-center mb-3">
                  <h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
                    Process Info
                  </h4>
                  <span class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
                    Refreshed at {format_diagnostics_time(@diagnostics_at)}
                  </span>
                </div>
                <div class="grid grid-cols-2 gap-3">
                  <div class="flex flex-col">
                    <span class="text-xs font-medium text-gray-600 dark:text-gray-300">Status</span>
                    <span class="text-sm tabular-nums text-gray-800 dark:text-gray-200">
                      {format_status(@diagnostics["info"]["status"])}
                    </span>
                  </div>
                  <div class="flex flex-col">
                    <span class="text-xs font-medium text-gray-600 dark:text-gray-300">Memory</span>
                    <span class="text-sm tabular-nums text-gray-800 dark:text-gray-200">
                      {format_bytes(@diagnostics["info"]["memory"])}
                    </span>
                  </div>
                  <div class="flex flex-col">
                    <span class="text-xs font-medium text-gray-600 dark:text-gray-300">
                      Message Queue
                    </span>
                    <span class="text-sm tabular-nums text-gray-800 dark:text-gray-200">
                      {format_number(@diagnostics["info"]["message_queue_len"])}
                    </span>
                  </div>
                  <div class="flex flex-col">
                    <span class="text-xs font-medium text-gray-600 dark:text-gray-300">
                      Reductions
                    </span>
                    <span class="text-sm tabular-nums text-gray-800 dark:text-gray-200">
                      {format_number(@diagnostics["info"]["reductions"])}
                    </span>
                  </div>
                  <div class="flex flex-col">
                    <span class="text-xs font-medium text-gray-600 dark:text-gray-300">
                      Heap Size
                    </span>
                    <span class="text-sm tabular-nums text-gray-800 dark:text-gray-200">
                      {format_number(@diagnostics["info"]["heap_size"])}
                    </span>
                  </div>
                  <div class="flex flex-col">
                    <span class="text-xs font-medium text-gray-600 dark:text-gray-300">
                      Stack Size
                    </span>
                    <span class="text-sm tabular-nums text-gray-800 dark:text-gray-200">
                      {format_number(@diagnostics["info"]["stack_size"])}
                    </span>
                  </div>
                </div>
              </div>

              <div class="relative bg-gray-50 dark:bg-gray-800 rounded-md p-4">
                <div class="flex justify-between items-start mb-3">
                  <h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
                    Current Stacktrace
                  </h4>
                  <button
                    :if={@diagnostics["info"]["current_stacktrace"]}
                    type="button"
                    id="copy-stacktrace"
                    class="w-9 h-9 -mr-2 -mt-2 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-white dark:hover:bg-gray-700 cursor-pointer"
                    data-title="Copy to clipboard"
                    phx-hook="Tippy"
                    phx-click={copy_to_clipboard(@diagnostics["info"]["current_stacktrace"])}
                  >
                    <Icons.icon name="icon-clipboard" class="w-4 h-4" />
                  </button>
                </div>
                <%= if @diagnostics["info"]["current_stacktrace"] do %>
                  <div class="space-y-1 max-h-64 overflow-y-auto">
                    <div
                      :for={frame <- parse_stacktrace(@diagnostics["info"]["current_stacktrace"])}
                      class="font-mono text-xs text-gray-600 dark:text-gray-400 py-1.5 px-2 bg-white dark:bg-gray-900 rounded border-l-2 border-gray-300 dark:border-gray-600"
                    >
                      {frame}
                    </div>
                  </div>
                <% else %>
                  <span class="text-sm text-gray-400 dark:text-gray-500">
                    No stacktrace available
                  </span>
                <% end %>
              </div>
            </div>
          <% else %>
            <div class="flex items-center space-x-2 px-2 text-gray-400 dark:text-gray-500">
              <Icons.icon name="icon-clock" class="w-5 h-5" />
              <span class="text-sm">
                <%= if executing?(@job) do %>
                  Waiting for diagnostics...
                <% else %>
                  Diagnostics are only available for executing jobs
                <% end %>
              </span>
            </div>
          <% end %>
        </div>
      </div>

      <div class="px-3 py-6 border-t border-gray-200 dark:border-gray-700">
        <.live_component
          id="detail-history-chart"
          module={HistoryChartComponent}
          job={@job}
          history={@history}
        />
      </div>

      <div class="px-3 py-6 border-t border-gray-200 dark:border-gray-700">
        <button
          id="errors-toggle"
          type="button"
          class="flex items-center w-full space-x-2 px-2 py-1.5 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
          phx-click={toggle_errors()}
        >
          <Icons.icon
            name="icon-chevron-right"
            id="errors-chevron"
            class={["w-5 h-5 transition-transform", if(Enum.any?(@job.errors), do: "rotate-90")]}
          />
          <span class="font-semibold">
            Errors
            <span :if={Enum.any?(@job.errors)} class="text-gray-400 font-normal">
              ({length(@job.errors)})
            </span>
          </span>
        </button>

        <div id="errors-content" class={["mt-3", if(Enum.empty?(@job.errors), do: "hidden")]}>
          <%= if Enum.any?(@job.errors) do %>
            <div class="flex items-center justify-end mb-3 space-x-4">
              <div class="flex items-center text-sm">
                <button
                  type="button"
                  phx-click="error-sort"
                  phx-value-sort="desc"
                  phx-target={@myself}
                  class={[
                    "px-2 py-1 cusror-pointer rounded-l-md border border-r-0 border-gray-300 dark:border-gray-600",
                    if(@error_sort == :desc,
                      do: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200",
                      else:
                        "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-750"
                    )
                  ]}
                >
                  Newest
                </button>
                <button
                  type="button"
                  phx-click="error-sort"
                  phx-value-sort="asc"
                  phx-target={@myself}
                  class={[
                    "px-2 py-1 cusror-pointer rounded-r-md border border-gray-300 dark:border-gray-600",
                    if(@error_sort == :asc,
                      do: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200",
                      else:
                        "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-750"
                    )
                  ]}
                >
                  Oldest
                </button>
              </div>

              <div class="flex items-center space-x-1">
                <button
                  type="button"
                  phx-click="error-nav"
                  phx-value-dir="prev"
                  phx-target={@myself}
                  disabled={@error_index == 0}
                  class={[
                    "p-1 rounded",
                    if(@error_index == 0,
                      do: "text-gray-300 dark:text-gray-500 cursor-not-allowed",
                      else:
                        "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
                    )
                  ]}
                >
                  <Icons.icon name="icon-chevron-left" class="w-5 h-5" />
                </button>
                <span class="text-sm text-gray-500 dark:text-gray-400 tabular min-w-[4rem] text-center">
                  {@error_index + 1} of {length(@job.errors)}
                </span>
                <button
                  type="button"
                  phx-click="error-nav"
                  phx-value-dir="next"
                  phx-target={@myself}
                  disabled={@error_index >= length(@job.errors) - 1}
                  class={[
                    "p-1 rounded",
                    if(@error_index >= length(@job.errors) - 1,
                      do: "text-gray-300 dark:text-gray-500 cursor-not-allowed",
                      else:
                        "text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
                    )
                  ]}
                >
                  <Icons.icon name="icon-chevron-right" class="w-5 h-5" />
                </button>
              </div>
            </div>

            <.error_entry errors={@job.errors} index={@error_index} sort={@error_sort} />
          <% else %>
            <div class="flex items-center space-x-2 px-2 text-gray-400 dark:text-gray-500">
              <Icons.icon name="icon-check-circle" class="w-5 h-5" />
              <span class="text-sm">No errors recorded</span>
            </div>
          <% end %>
        </div>
      </div>

      <div class="px-3 py-6 border-t border-gray-200 dark:border-gray-700">
        <button
          id="edit-toggle"
          type="button"
          class="flex items-center w-full space-x-2 px-2 py-1.5 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
          phx-click={toggle_edit()}
        >
          <Icons.icon
            name="icon-chevron-right"
            id="edit-chevron"
            class={["w-5 h-5 transition-transform", if(not executing?(@job), do: "rotate-90")]}
          />
          <span class="font-semibold">Edit Job</span>
          <span
            :if={executing?(@job)}
            id="edit-hint"
            class="flex items-center"
            data-title="Executing jobs can't be edited"
            phx-hook="Tippy"
          >
            <Icons.icon name="icon-info-circle" class="w-4 h-4 text-gray-400" />
          </span>
        </button>

        <div id="edit-content" class={["mt-3", if(executing?(@job), do: "hidden")]}>
          <fieldset disabled={executing?(@job) or not can?(:update_jobs, @access)}>
            <form
              id="job-edit-form"
              class="grid grid-cols-4 gap-4 bg-gray-50 dark:bg-gray-800 rounded-md p-4"
              phx-change="edit-change"
              phx-submit="save-job"
              phx-target={@myself}
            >
              <.form_field label="Worker" name="worker" value={@form.worker} />

              <.select_field
                label="Queue"
                name="queue"
                value={@form.queue}
                options={queue_options(@queues)}
              />

              <.form_field
                label="Priority"
                name="priority"
                value={@form.priority}
                type="number"
                placeholder="0"
              />

              <.form_field
                label="Max Attempts"
                name="max_attempts"
                value={@form.max_attempts}
                type="number"
                placeholder="20"
              />

              <.form_field
                label="Scheduled At"
                name="scheduled_at"
                value={@form.scheduled_at}
                type="datetime-local"
              />

              <.form_field
                label="Tags"
                name="tags"
                value={@form.tags}
                placeholder="tag1, tag2"
                colspan="col-span-3"
              />

              <.form_field
                label="Args"
                name="args"
                value={@form.args}
                colspan="col-span-4"
                type="textarea"
                placeholder="{}"
                rows={3}
              />

              <div class="col-span-4 flex justify-end items-center gap-3 pt-4">
                <button
                  type="button"
                  phx-click={cancel_edit(@myself)}
                  class="px-6 py-2 text-gray-600 dark:text-gray-400 text-sm font-medium rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2 cursor-pointer"
                >
                  Cancel
                </button>
                <button
                  type="submit"
                  disabled={not @edit_changed? or not can?(:update_jobs, @access)}
                  class="px-6 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
                >
                  Save Changes
                </button>
              </div>
            </form>
          </fieldset>
        </div>
      </div>
    </div>
    """
  end

  # Pro Badge

  attr :id, :string, required: true
  attr :tooltip, :string, required: true

  defp pro_badge(assigns) do
    ~H"""
    <span
      id={@id}
      class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
      data-title={@tooltip}
      phx-hook="Tippy"
    >
      Pro
    </span>
    """
  end

  defp stale_badge(assigns) do
    ~H"""
    <span
      id="diagnostics-stale-badge"
      class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"
      data-title="Diagnostics data is stale because the job is no longer executing"
      phx-hook="Tippy"
    >
      Stale
    </span>
    """
  end

  # Job Data Section

  attr :job, :map, required: true
  attr :resolver, :any, required: true

  defp job_data_section(assigns) do
    ~H"""
    <div class="px-3 py-6 border-t border-gray-200 dark:border-gray-700">
      <button
        id="job-data-toggle"
        type="button"
        class="flex items-center w-full space-x-2 px-2 py-1.5 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
        phx-click={toggle_job_data()}
      >
        <Icons.icon
          name="icon-chevron-right"
          id="job-data-chevron"
          class="w-5 h-5 transition-transform rotate-90"
        />
        <span class="font-semibold">Job Data</span>
      </button>

      <div id="job-data-content" class="mt-3">
        <div class="grid grid-cols-2 gap-4">
          <div id="job-args" class="relative bg-gray-50 dark:bg-gray-800 rounded-md p-4">
            <div class="flex justify-between items-start mb-2">
              <h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
                Args
              </h4>
              <button
                type="button"
                id="copy-args"
                class="w-9 h-9 -mr-2 -mt-2 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-white dark:hover:bg-gray-700 cursor-pointer"
                data-title="Copy to clipboard"
                phx-hook="Tippy"
                phx-click={copy_to_clipboard(format_args(@job, @resolver))}
              >
                <Icons.icon name="icon-clipboard" class="w-4 h-4" />
              </button>
            </div>
            <pre class="font-mono text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-all">{format_args(@job, @resolver)}</pre>
          </div>

          <div class="relative bg-gray-50 dark:bg-gray-800 rounded-md p-4">
            <div class="flex justify-between items-start mb-2">
              <h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
                Meta
              </h4>
              <button
                type="button"
                id="copy-meta"
                class="w-9 h-9 -mr-2 -mt-2 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-white dark:hover:bg-gray-700 cursor-pointer"
                data-title="Copy to clipboard"
                phx-hook="Tippy"
                phx-click={copy_to_clipboard(format_meta(@job, @resolver))}
              >
                <Icons.icon name="icon-clipboard" class="w-4 h-4" />
              </button>
            </div>
            <pre class="font-mono text-sm text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-all">{format_meta(@job, @resolver)}</pre>
          </div>
        </div>

        <div :if={@job.meta["recorded"]} class="mt-4">
          <div class="relative bg-gray-50 dark:bg-gray-800 rounded-md p-4">
            <div class="flex justify-between items-start mb-2">
              <div class="flex items-center space-x-2">
                <h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
                  Recorded Output
                </h4>
                <.pro_badge id="recorded-pro-badge" tooltip="Recording from Oban.Pro.Worker" />
              </div>
              <button
                type="button"
                id="copy-recorded"
                class="w-9 h-9 -mr-2 -mt-2 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-white dark:hover:bg-gray-700 cursor-pointer"
                data-title="Copy to clipboard"
                phx-hook="Tippy"
                phx-click={copy_to_clipboard(format_recorded(@job, @resolver))}
              >
                <Icons.icon name="icon-clipboard" class="w-4 h-4" />
              </button>
            </div>
            <pre class="font-mono text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-all">{format_recorded(@job, @resolver)}</pre>
          </div>
        </div>

        <div :if={signal_status(@job) != :none} class="mt-4">
          <div class="relative bg-gray-50 dark:bg-gray-800 rounded-md p-4">
            <div class="flex justify-between items-start mb-2">
              <div class="flex items-center space-x-2">
                <h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
                  {signal_heading(@job)}
                </h4>
                <.pro_badge id="signal-pro-badge" tooltip="Awaitable signal from Oban.Pro.Worker" />
              </div>
              <button
                type="button"
                id="copy-signal"
                class={[
                  "w-9 h-9 -mr-2 -mt-2 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-white dark:hover:bg-gray-700 cursor-pointer",
                  signal_status(@job) != :received && "invisible"
                ]}
                data-title="Copy to clipboard"
                phx-hook="Tippy"
                phx-click={copy_to_clipboard(format_signal(@job, @resolver))}
              >
                <Icons.icon name="icon-clipboard" class="w-4 h-4" />
              </button>
            </div>
            <pre class="font-mono text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-all">{format_signal(@job, @resolver)}</pre>
          </div>
        </div>
      </div>
    </div>
    """
  end

  # Handlers

  @impl Phoenix.LiveComponent
  def handle_event("cancel", _params, socket) do
    if can?(:cancel_jobs, socket.assigns.access) do
      send(self(), {:cancel_job, socket.assigns.job})
    end

    {:noreply, socket}
  end

  def handle_event("delete", _params, socket) do
    if can?(:delete_jobs, socket.assigns.access) do
      send(self(), {:delete_job, socket.assigns.job})
    end

    {:noreply, socket}
  end

  def handle_event("retry", _params, socket) do
    if can?(:retry_jobs, socket.assigns.access) do
      send(self(), {:retry_job, socket.assigns.job})
    end

    {:noreply, socket}
  end

  def handle_event("toggle-diagnostics", _params, socket) do
    {:noreply, assign(socket, :diagnostics_open?, not socket.assigns.diagnostics_open?)}
  end

  def handle_event("error-sort", %{"sort" => sort}, socket) do
    sort = String.to_existing_atom(sort)

    {:noreply, assign(socket, error_sort: sort, error_index: 0)}
  end

  def handle_event("error-nav", %{"dir" => "prev"}, socket) do
    index = max(0, socket.assigns.error_index - 1)

    {:noreply, assign(socket, error_index: index)}
  end

  def handle_event("error-nav", %{"dir" => "next"}, socket) do
    max_index = length(socket.assigns.job.errors) - 1
    index = min(max_index, socket.assigns.error_index + 1)
    {:noreply, assign(socket, error_index: index)}
  end

  def handle_event("edit-change", params, socket) do
    job = Map.update!(socket.assigns.job, :scheduled_at, &DateTime.truncate(&1, :second))

    form = %{
      worker: params["worker"],
      queue: params["queue"],
      priority: params["priority"],
      max_attempts: params["max_attempts"],
      scheduled_at: params["scheduled_at"],
      tags: params["tags"],
      args: params["args"]
    }

    changed? =
      params
      |> parse_edit_params(job)
      |> Enum.any?(fn {_key, val} -> not is_nil(val) end)

    {:noreply, assign(socket, form: form, edit_changed?: changed?)}
  end

  def handle_event("cancel-edit", _params, socket) do
    form = form_from_job(socket.assigns.job)

    {:noreply, assign(socket, form: form, edit_changed?: false)}
  end

  def handle_event("save-job", params, socket) do
    job = socket.assigns.job

    if can?(:update_jobs, socket.assigns.access) do
      changes =
        params
        |> parse_edit_params(job)
        |> Enum.reject(fn {_key, val} -> is_nil(val) end)
        |> Map.new()

      if map_size(changes) > 0 do
        send(self(), {:update_job, job, changes})
      end
    end

    {:noreply, assign(socket, edit_changed?: false)}
  end

  # Helpers

  defp format_args(job, resolver) do
    Resolver.call_with_fallback(resolver, :format_job_args, [job])
  end

  defp format_meta(%{meta: meta} = job, resolver) do
    job = %{job | meta: Map.drop(meta, ["return", "signal"])}

    Resolver.call_with_fallback(resolver, :format_job_meta, [job])
  end

  defp format_recorded(%{meta: meta} = job, resolver) do
    case meta do
      %{"recorded" => true, "return" => value} ->
        Resolver.call_with_fallback(resolver, :format_recorded, [value, job])

      %{"recorded" => true} ->
        "No Recording Yet"

      _ ->
        "Recording Not Enabled"
    end
  end

  defp format_signal(%{meta: %{"signal" => value}} = job, resolver) do
    Resolver.call_with_fallback(resolver, :format_signal, [value, job])
  end

  defp format_signal(%{meta: %{"wait_until" => wait_until}}, _resolver) do
    case wait_until_to_datetime(wait_until) do
      {:ok, datetime} -> "Deadline #{Timing.datetime_to_words(datetime)}"
      :infinity -> "No deadline"
      :error -> ""
    end
  end

  defp signal_status(%{meta: %{"signal" => _}}), do: :received
  defp signal_status(%{meta: %{"wait_until" => _}}), do: :awaiting
  defp signal_status(_), do: :none

  defp signal_heading(job) do
    case signal_status(job) do
      :received -> "Received Signal"
      :awaiting -> "Awaiting Signal"
      :none -> "Signal"
    end
  end

  defp wait_until_to_datetime("infinity"), do: :infinity

  defp wait_until_to_datetime(ms) when is_integer(ms) do
    case DateTime.from_unix(ms, :millisecond) do
      {:ok, datetime} -> {:ok, datetime |> DateTime.to_naive() |> NaiveDateTime.truncate(:second)}
      _ -> :error
    end
  end

  defp wait_until_to_datetime(_), do: :error

  defp error_entry(assigns) do
    error =
      assigns.errors
      |> Enum.sort_by(& &1["attempt"], assigns.sort)
      |> Enum.at(assigns.index)

    {message, stacktrace} = parse_error(error["error"])

    assigns = assign(assigns, error: error, message: message, stacktrace: stacktrace)

    ~H"""
    <div class="mb-8 p-4 bg-gray-50 dark:bg-gray-800 rounded-md">
      <div class="flex items-center justify-between mb-3 text-sm text-gray-500 dark:text-gray-400">
        <span>Attempt {@error["attempt"]}</span>
        <span>
          {Timing.datetime_to_words(@error["at"])}
          <span class="text-gray-400 dark:text-gray-500">({@error["at"]})</span>
        </span>
      </div>

      <div class="font-mono text-base font-medium text-gray-800 dark:text-gray-200 mb-4">
        {@message}
      </div>

      <div :if={@stacktrace != []} class="space-y-1">
        <div
          :for={frame <- @stacktrace}
          class="font-mono text-sm text-gray-600 dark:text-gray-400 py-1.5 px-2 bg-white dark:bg-gray-900 rounded border-l-2 border-gray-300 dark:border-gray-600"
        >
          {frame}
        </div>
      </div>
    </div>
    """
  end

  defp parse_error(error) do
    case String.split(error, "\n", parts: 2) do
      [message, rest] ->
        stacktrace =
          rest
          |> String.split("\n")
          |> Enum.map(&String.trim/1)
          |> Enum.reject(&(&1 == ""))

        {message, stacktrace}

      [message] ->
        {message, []}
    end
  end

  defp toggle_errors do
    %JS{}
    |> JS.toggle(to: "#errors-content", in: "fade-in-scale", out: "fade-out-scale")
    |> JS.add_class("rotate-90", to: "#errors-chevron:not(.rotate-90)")
    |> JS.remove_class("rotate-90", to: "#errors-chevron.rotate-90")
  end

  defp toggle_job_data do
    %JS{}
    |> JS.toggle(to: "#job-data-content", in: "fade-in-scale", out: "fade-out-scale")
    |> JS.add_class("rotate-90", to: "#job-data-chevron:not(.rotate-90)")
    |> JS.remove_class("rotate-90", to: "#job-data-chevron.rotate-90")
  end

  defp job_title(job), do: Map.get(job.meta, "decorated_name", job.worker)

  defp workflow_display_name(job) do
    job.meta["workflow_name"] || job.meta["workflow_id"]
  end

  defp toggle_edit do
    %JS{}
    |> JS.toggle(to: "#edit-content", in: "fade-in-scale", out: "fade-out-scale")
    |> JS.add_class("rotate-90", to: "#edit-chevron:not(.rotate-90)")
    |> JS.remove_class("rotate-90", to: "#edit-chevron.rotate-90")
  end

  defp scroll_to_edit do
    %JS{}
    |> JS.show(to: "#edit-content", transition: "fade-in-scale")
    |> JS.add_class("rotate-90", to: "#edit-chevron")
    |> JS.focus(to: "#job-edit-form input")
  end

  defp cancel_edit(target) do
    %JS{}
    |> JS.hide(to: "#edit-content", transition: "fade-out-scale")
    |> JS.remove_class("rotate-90", to: "#edit-chevron")
    |> JS.dispatch("reset", to: "#job-edit-form")
    |> JS.push("cancel-edit", target: target)
  end

  defp copy_to_clipboard(text) do
    JS.dispatch("phx:copy-to-clipboard", detail: %{text: text})
  end

  defp executing?(%{state: state}), do: state == "executing"

  defp format_bytes(nil), do: "—"
  defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
  defp format_bytes(bytes) when bytes < 1024 * 1024, do: "#{Float.round(bytes / 1024, 1)} KB"
  defp format_bytes(bytes), do: "#{Float.round(bytes / 1024 / 1024, 1)} MB"

  defp format_number(nil), do: "—"
  defp format_number(num) when is_integer(num), do: integer_to_delimited(num)

  defp format_status(nil), do: "—"
  defp format_status(status) when is_binary(status), do: String.capitalize(status)

  defp format_diagnostics_time(unix_time) when is_integer(unix_time) do
    unix_time
    |> DateTime.from_unix!()
    |> Calendar.strftime("%H:%M:%S")
  end

  defp parse_stacktrace(nil), do: []

  defp parse_stacktrace(stacktrace) when is_binary(stacktrace) do
    stacktrace
    |> String.split("\n")
    |> Enum.map(&String.trim/1)
    |> Enum.reject(&(&1 == ""))
  end

  defp parse_edit_params(params, job) do
    [
      worker: new_val?(parse_string(params["worker"]), job.worker),
      queue: new_val?(parse_string(params["queue"]), job.queue),
      priority: new_val?(parse_int(params["priority"]), job.priority),
      max_attempts: new_val?(parse_int(params["max_attempts"]), job.max_attempts),
      scheduled_at: new_val?(parse_datetime(params["scheduled_at"]), job.scheduled_at),
      tags: new_val?(parse_tags(params["tags"]), job.tags),
      args: new_val?(parse_json(params["args"]), job.args)
    ]
  end

  defp new_val?(nil, _current), do: nil
  defp new_val?("", _current), do: nil
  defp new_val?(val, val), do: nil
  defp new_val?(val, _current), do: val

  defp parse_datetime(nil), do: nil
  defp parse_datetime(""), do: nil

  defp parse_datetime(str) when is_binary(str) do
    case DateTime.from_iso8601(str <> "Z") do
      {:ok, datetime, 0} -> datetime
      _ -> nil
    end
  end

  defp format_datetime(nil), do: nil

  defp format_datetime(%DateTime{} = datetime) do
    datetime
    |> DateTime.truncate(:second)
    |> DateTime.to_iso8601()
    |> String.slice(0, 19)
  end

  defp format_job_tags(nil), do: nil
  defp format_job_tags([]), do: nil
  defp format_job_tags(tags) when is_list(tags), do: Enum.join(tags, ", ")

  defp format_job_args(args) when is_map(args), do: Oban.JSON.encode!(args)
  defp format_job_args(_), do: "{}"
end