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