Current section
Files
Jump to
Current section
Files
lib/oban/web/live/crons/table_component.ex
defmodule Oban.Web.Crons.TableComponent do
use Oban.Web, :live_component
import Oban.Web.Crons.Helpers, only: [maybe_to_unix: 1, show_name?: 1, state_icon: 1]
alias Oban.Web.{Colors, Cron}
@sparkline_count 60
@sparkline_height 16
@sparkline_bar_width 4
@sparkline_gap 1
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div id="crons-table" class="min-w-full">
<ul class="flex items-center border-b border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500">
<.header label="name" class="pl-3 w-1/3 text-left" />
<div class="ml-auto flex items-center space-x-6">
<.header label="history" class="w-80 text-center" />
<.header label="schedule" class="w-32 text-right" />
<.header label="last run" class="w-32 text-right" />
<.header label="next run" class="w-32 text-right" />
<.header label="status" class="w-20 pr-4 text-right" />
</div>
</ul>
<div :if={Enum.empty?(@crontab)} class="py-16 px-6 text-center">
<Icons.clock class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-4 text-xl font-semibold text-gray-900 dark:text-gray-100">No crons</h3>
<p class="mt-2 text-base text-gray-500 dark:text-gray-400 max-w-md mx-auto">
Crons run jobs on a schedule. Configure them in your Oban supervisor or create them dynamically.
</p>
<div class="mt-4">
<a
href="https://hexdocs.pm/oban/periodic_jobs.html"
target="_blank"
rel="noopener"
class="text-base font-medium text-violet-600 hover:text-violet-500 dark:text-violet-400 dark:hover:text-violet-300"
>
Learn about crons <span aria-hidden="true">→</span>
</a>
</div>
</div>
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
<.cron_row :for={cron <- @crontab} id={cron.name} cron={cron} />
</ul>
</div>
"""
end
attr :label, :string, required: true
attr :class, :string, default: ""
defp header(assigns) do
~H"""
<span class={[@class, "text-xs font-medium uppercase tracking-wider py-1.5"]}>
{@label}
</span>
"""
end
attr :history, :list, required: true
attr :id, :string, required: true
defp sparkline(assigns) do
history = Enum.take(assigns.history, -@sparkline_count)
offset = @sparkline_count - length(history)
bars =
for {job, index} <- Enum.with_index(history) do
x = (offset + index) * (@sparkline_bar_width + @sparkline_gap)
%{x: x, color: state_color(job.state)}
end
tooltip_data =
for job <- history do
unix =
(job.finished_at || job.attempted_at || job.scheduled_at)
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_unix(:millisecond)
%{timestamp: unix, state: job.state}
end
placeholders =
for slot <- 0..(@sparkline_count - 1) do
%{x: slot * (@sparkline_bar_width + @sparkline_gap)}
end
width = @sparkline_count * (@sparkline_bar_width + @sparkline_gap)
assigns =
assigns
|> assign(bars: bars, placeholders: placeholders, width: width, offset: offset)
|> assign(height: @sparkline_height, bar_width: @sparkline_bar_width)
|> assign(tooltip_data: tooltip_data)
~H"""
<svg
id={@id}
width={@width}
height={@height}
viewBox={"0 0 #{@width} #{@height}"}
class="flex-shrink-0 cursor-pointer"
phx-hook="CronSparkline"
data-tooltip={Oban.JSON.encode!(@tooltip_data)}
data-bar-width={@bar_width}
data-offset={@offset}
>
<rect
:for={placeholder <- @placeholders}
x={placeholder.x}
y={@height - 2}
width={@bar_width}
height="2"
fill="#e5e7eb"
class="dark:fill-gray-700"
rx="0.5"
/>
<rect
:for={bar <- @bars}
x={bar.x}
y="0"
width={@bar_width}
height={@height}
fill={bar.color}
rx="1"
/>
</svg>
"""
end
defp state_color(state), do: Colors.state_hex(state)
attr :cron, Cron
attr :id, :string
defp cron_row(assigns) do
~H"""
<li id={"cron-#{@id}"} class="flex items-center hover:bg-gray-50 dark:hover:bg-gray-950/30">
<.link patch={oban_path([:crons, @cron.name])} class="pl-3 py-3.5 flex flex-grow items-center">
<div class="w-1/3">
<span class="font-semibold text-sm text-gray-700 dark:text-gray-300">
{@cron.worker}
<span :if={show_name?(@cron)} class="font-normal text-gray-500 dark:text-gray-400">
({@cron.name})
</span>
</span>
<div
:if={@cron.dynamic? or has_tags?(@cron.opts) or format_opts(@cron.opts)}
class="flex flex-wrap items-center gap-1.5 mt-1"
>
<span
:if={@cron.dynamic?}
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"
>
dynamic
</span>
<span
:for={tag <- get_tags(@cron.opts)}
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
>
{tag}
</span>
<samp
:if={format_opts(@cron.opts)}
class="font-mono text-xs text-gray-500 dark:text-gray-400"
>
{format_opts(@cron.opts)}
</samp>
</div>
</div>
<div class="ml-auto flex items-center space-x-6 tabular text-gray-500 dark:text-gray-300">
<div class="w-80 flex justify-center">
<.sparkline id={"sparkline-#{@cron.name}"} history={@cron.history} />
</div>
<span class="w-32 text-right font-mono text-sm">
{@cron.expression}
</span>
<span
class="w-32 text-right text-sm"
id={"cron-lts-#{@cron.name}"}
data-timestamp={maybe_to_unix(@cron.last_at)}
phx-hook="Relativize"
phx-update="ignore"
>
-
</span>
<span
class="w-32 text-right text-sm"
id={"cron-nts-#{@cron.name}"}
data-timestamp={maybe_to_unix(@cron.next_at)}
phx-hook="Relativize"
phx-update="ignore"
>
-
</span>
<div class="w-20 pr-4 flex justify-end">
<span
id={"cron-state-icon-#{@cron.name}"}
phx-hook="Tippy"
data-title={state_title(@cron)}
>
<.state_icon state={@cron.last_state} paused={@cron.paused?} />
</span>
</div>
</div>
</.link>
</li>
"""
end
defp state_title(%{paused?: true}), do: "Paused"
defp state_title(cron) do
case cron.last_state do
nil ->
"Unknown, no previous runs"
state ->
"#{String.capitalize(state)} as of #{NaiveDateTime.truncate(cron.last_at, :second)}"
end
end
defp format_opts(opts) when map_size(opts) == 0, do: nil
defp format_opts(opts) do
opts
|> Map.drop(["tags"])
|> case do
filtered when map_size(filtered) == 0 ->
nil
filtered ->
filtered
|> Enum.map_join(", ", fn {key, val} -> "#{key}: #{inspect(val)}" end)
|> truncate(0..98)
end
end
defp has_tags?(opts), do: Map.has_key?(opts, "tags") and opts["tags"] != []
defp get_tags(opts), do: Map.get(opts, "tags", [])
end