Skip to content

Commit 0ebeda1

Browse files
committed
Display timestamps and last state for cron jobs
1 parent 0faac96 commit 0ebeda1

File tree

6 files changed

+177
-51
lines changed

6 files changed

+177
-51
lines changed

lib/oban/web/cron.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
defmodule Oban.Web.Cron do
2+
@moduledoc false
3+
4+
defstruct [:expression, :worker, :opts, :next_at, :last_at, :last_state]
5+
6+
def name(%__MODULE__{worker: worker, opts: opts}) do
7+
base = String.replace(worker, ".", "-")
8+
hash = :erlang.phash2(opts)
9+
10+
"#{base}-#{hash}"
11+
end
12+
end

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
200200
<div class="mb-12">
201201
<h4 class="mb-3 flex items-center space-x-2">
202202
<div class="text-sm font-semibold">
203-
Attempt {attempt}&mdash;{Timing.iso8601_to_words(at)}
203+
Attempt {attempt}&mdash;{Timing.datetime_to_words(at)}
204204
</div>
205205
<div id={at} data-title={at} phx-hook="Tippy">
206206
<Icons.info_circle class="w-5 h-5 text-gray-500 dark:text-gray-400" />

lib/oban/web/pages/crons_page.ex

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule Oban.Web.CronsPage do
33

44
use Oban.Web, :live_component
55

6-
alias Oban.Web.Page
6+
alias Oban.Web.{Cron, CronQuery, Page, Timing}
77

88
@impl Phoenix.LiveComponent
99
def render(assigns) do
@@ -15,11 +15,7 @@ defmodule Oban.Web.CronsPage do
1515
class="pr-3 flex items-center border-b border-gray-200 dark:border-gray-700"
1616
>
1717
<div class="flex-none flex items-center pr-12">
18-
<Core.all_checkbox
19-
click="toggle-select-all"
20-
checked={:none}
21-
myself={@myself}
22-
/>
18+
<Core.all_checkbox click="toggle-select-all" checked={:none} myself={@myself} />
2319
2420
<h2 class="text-lg dark:text-gray-200 leading-4 font-bold">Crons</h2>
2521
</div>
@@ -30,7 +26,8 @@ defmodule Oban.Web.CronsPage do
3026
<.header label="name" class="ml-12 w-1/3 text-left" />
3127
<div class="ml-auto flex items-center space-x-6">
3228
<.header label="schedule" class="w-32 text-right" />
33-
<.header label="activity" class="w-24 text-right" />
29+
<.header label="last run" class="w-32 text-right" />
30+
<.header label="next run" class="w-32 text-right" />
3431
<.header label="status" class="w-20 pr-3 text-right" />
3532
</div>
3633
</ul>
@@ -42,20 +39,16 @@ defmodule Oban.Web.CronsPage do
4239
</div>
4340
4441
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
45-
<.cron_row
46-
:for={{expr, worker, opts} <- @crontab}
47-
expr={expr}
48-
worker={worker}
49-
opts={opts}
50-
myself={@myself}
51-
/>
42+
<.cron_row :for={cron <- @crontab} cron={cron} myself={@myself} />
5243
</ul>
5344
</div>
5445
</div>
5546
</div>
5647
"""
5748
end
5849

50+
# Components
51+
5952
attr :label, :string, required: true
6053
attr :class, :string, default: ""
6154

@@ -67,47 +60,49 @@ defmodule Oban.Web.CronsPage do
6760
"""
6861
end
6962

70-
attr :expr, :string
71-
attr :worker, :string
72-
attr :opts, :map
63+
attr :cron, Cron
7364
attr :myself, :any
7465

7566
defp cron_row(assigns) do
7667
~H"""
7768
<li
78-
id={"cron-#{cron_name(@worker, @opts)}"}
69+
id={"cron-#{Cron.name(@cron)}"}
7970
class="flex items-center hover:bg-gray-50 dark:hover:bg-gray-950/30"
8071
>
81-
<Core.row_checkbox
82-
click="toggle-select"
83-
value={@worker}
84-
checked={false}
85-
myself={@myself}
86-
/>
72+
<Core.row_checkbox click="toggle-select" value={@cron.worker} checked={false} myself={@myself} />
8773
8874
<div class="py-2.5 flex flex-grow items-center">
8975
<div class="w-1/3">
9076
<span class="block font-semibold text-sm text-gray-700 dark:text-gray-300">
91-
{@worker}
77+
{@cron.worker}
9278
</span>
9379
9480
<samp class="font-mono truncate text-xs text-gray-500 dark:text-gray-400">
95-
{format_opts(@opts)}
81+
{format_opts(@cron.opts)}
9682
</samp>
9783
</div>
9884
9985
<div class="ml-auto flex items-center space-x-6 tabular text-gray-500 dark:text-gray-300">
10086
<span class="w-32 text-right font-mono text-sm">
101-
{@expr}
87+
{@cron.expression}
88+
</span>
89+
90+
<span class="w-32 text-right text-sm">
91+
{Timing.datetime_to_words(@cron.last_at)}
10292
</span>
10393
104-
<span class="w-24 text-right">
105-
-
94+
<span class="w-32 text-right text-sm">
95+
{Timing.datetime_to_words(@cron.next_at)}
10696
</span>
10797
10898
<div class="w-20 pr-3 flex justify-end items-center space-x-1">
109-
<span class="py-1.5 px-2 text-xs rounded-md bg-gray-100 dark:bg-gray-950">
110-
Active
99+
<span
100+
id={"cron-state-icon-#{@cron.worker}"}
101+
class="py-1.5 px-2 text-xs"
102+
phx-hook="Tippy"
103+
data-title={state_title(@cron)}
104+
>
105+
<.state_icon state={@cron.last_state} />
111106
</span>
112107
</div>
113108
</div>
@@ -116,16 +111,40 @@ defmodule Oban.Web.CronsPage do
116111
"""
117112
end
118113

119-
defp cron_name(worker, opts) do
120-
base = String.replace(worker, ".", "-")
121-
hash = :erlang.phash2(opts)
114+
attr :state, :string, required: true
115+
116+
defp state_icon(assigns) do
117+
~H"""
118+
<%= case @state do %>
119+
<% "available" -> %>
120+
<Icons.pause_circle class="w-5 h-5 text-teal-400" />
121+
<% "cancelled" -> %>
122+
<Icons.x_circle class="w-5 h-5 text-violet-400" />
123+
<% "completed" -> %>
124+
<Icons.check_circle class="w-5 h-5 text-cyan-400" />
125+
<% "discarded" -> %>
126+
<Icons.exclamation_circle class="w-5 h-5 text-rose-400" />
127+
<% "executing" -> %>
128+
<Icons.play_circle class="w-5 h-5 text-orange-400" />
129+
<% "retryable" -> %>
130+
<Icons.arrow_path class="w-5 h-5 text-yellow-400" />
131+
<% "scheduled" -> %>
132+
<Icons.play_circle class="w-5 h-5 text-emerald-400" />
133+
<% _ -> %>
134+
<Icons.minus_circle class="w-5 h-5 text-gray-400" />
135+
<% end %>
136+
"""
137+
end
122138

123-
"#{base}-#{hash}"
139+
defp state_title(cron) do
140+
case cron.last_state do
141+
nil -> "Unknown, no previous runs"
142+
state -> "#{String.capitalize(state)} as of #{NaiveDateTime.truncate(cron.last_at, :second)}"
143+
end
124144
end
125145

126146
@impl Page
127147
def handle_mount(socket) do
128-
129148
assign_new(socket, :crontab, fn -> crontab(socket.assigns.conf) end)
130149
end
131150

@@ -136,6 +155,8 @@ defmodule Oban.Web.CronsPage do
136155

137156
@impl Page
138157
def handle_params(_params, _uri, socket) do
158+
socket = assign(socket, page_title: page_title("Crons"))
159+
139160
{:noreply, socket}
140161
end
141162

@@ -156,13 +177,15 @@ defmodule Oban.Web.CronsPage do
156177
{:noreply, socket}
157178
end
158179

159-
defp crontab(conf), do: Oban.Met.crontab(conf.name)
180+
defp crontab(conf) do
181+
CronQuery.all_crons(%{}, conf)
182+
end
160183

161-
defp format_opts(opts) when map_size(opts) == 0, do: "%{}"
184+
defp format_opts(opts) when map_size(opts) == 0, do: "[]"
162185

163186
defp format_opts(opts) do
164187
opts
165-
|> inspect(charlists: :as_lists, limit: :infinity)
188+
|> Enum.map_join(", ", fn {key, val} -> "#{key}: #{inspect(val)}" end)
166189
|> truncate(0..98)
167190
end
168191
end

lib/oban/web/queries/cron_query.ex

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
defmodule Oban.Web.CronQuery do
2+
@moduledoc false
3+
4+
import Ecto.Query
5+
6+
alias Oban.Cron.Expression
7+
alias Oban.{Job, Met, Repo}
8+
alias Oban.Web.Cron
9+
10+
def all_crons(_params, conf) do
11+
# TODO: Cache these values and avoid running the query too frequently
12+
crontab = Met.crontab(conf.name)
13+
workers = Enum.map(crontab, &elem(&1, 1))
14+
history = crontab_history(workers, conf)
15+
16+
for {expr, worker, opts} <- crontab do
17+
fields = [
18+
expression: expr,
19+
worker: worker,
20+
opts: opts,
21+
next_at: next_at(expr),
22+
last_at: last_at(history, worker),
23+
last_state: get_in(history, [worker, :state])
24+
]
25+
26+
struct!(Cron, fields)
27+
end
28+
end
29+
30+
# TODO: Support mysql/sqlite
31+
defp crontab_history(workers, conf) do
32+
query =
33+
from(
34+
f in fragment("json_array_elements_text(?)", ^workers),
35+
as: :list,
36+
inner_lateral_join:
37+
j in subquery(
38+
Job
39+
|> select(~w(state attempted_at cancelled_at completed_at discarded_at scheduled_at)a)
40+
|> where([j], j.worker == parent_as(:list).value)
41+
|> order_by(desc: :id)
42+
|> limit(1)
43+
),
44+
on: true,
45+
select:
46+
{f.value,
47+
%{
48+
state: j.state,
49+
attempted_at: j.attempted_at,
50+
cancelled_at: j.cancelled_at,
51+
completed_at: j.completed_at,
52+
discarded_at: j.discarded_at,
53+
scheduled_at: j.scheduled_at
54+
}}
55+
)
56+
57+
conf
58+
|> Repo.all(query)
59+
|> Map.new()
60+
end
61+
62+
defp last_at(history, worker) do
63+
case Map.get(history, worker) do
64+
%{state: state, scheduled_at: at} when state in ~w(available scheduled retryable) -> at
65+
%{state: "executing", attempted_at: at} -> at
66+
%{state: "cancelled", cancelled_at: at} -> at
67+
%{state: "completed", completed_at: at} -> at
68+
%{state: "discarded", discarded_at: at} -> at
69+
_ -> nil
70+
end
71+
end
72+
73+
defp next_at(expression) do
74+
expression
75+
|> Expression.parse!()
76+
|> Expression.next_at()
77+
end
78+
end

lib/oban/web/timing.ex

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ defmodule Oban.Web.Timing do
138138
iex> Oban.Web.Timing.to_words(-1, relative: false)
139139
"1s"
140140
"""
141-
def to_words(ellapsed, opts \\ [relative: true]) when is_integer(ellapsed) do
141+
def to_words(ellapsed, opts \\ []) when is_integer(ellapsed) do
142142
distance =
143143
case abs(ellapsed) do
144144
0 -> "now"
@@ -151,7 +151,7 @@ defmodule Oban.Web.Timing do
151151
end
152152

153153
cond do
154-
not opts[:relative] -> distance
154+
opts[:relative] == false -> distance
155155
ellapsed < 0 -> "#{distance} ago"
156156
ellapsed > 0 -> "in #{distance}"
157157
true -> distance
@@ -252,14 +252,22 @@ defmodule Oban.Web.Timing do
252252
defp truncate_sec(datetime), do: NaiveDateTime.truncate(datetime, :second)
253253

254254
@doc """
255-
Convert a stringified timestamp (i.e. from an error) into a relative time.
255+
Convert a naive date time or iso8601 stringified version to a relative time.
256256
"""
257-
def iso8601_to_words(iso8601, now \\ NaiveDateTime.utc_now()) do
257+
def datetime_to_words(datetime, opts \\ [])
258+
259+
def datetime_to_words(nil, _), do: "-"
260+
261+
def datetime_to_words(iso8601, opts) when is_binary(iso8601) do
258262
{:ok, datetime} = NaiveDateTime.from_iso8601(iso8601)
259263

264+
datetime_to_words(datetime, opts)
265+
end
266+
267+
def datetime_to_words(datetime, opts) do
260268
datetime
261-
|> NaiveDateTime.diff(now)
262-
|> to_words()
269+
|> NaiveDateTime.diff(NaiveDateTime.utc_now())
270+
|> to_words(opts)
263271
end
264272

265273
defp pad(time, places \\ 2) do

test/oban/web/pages/crons/index_test.exs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@ defmodule Oban.Web.Pages.Crons.IndexTest do
1616
use Oban.Web.Case, async: true
1717

1818
setup do
19-
start_supervised_oban!(plugins: [{Oban.Plugins.Cron, crontab: [
20-
{"* * * * *", Oban.Workers.CronA},
21-
{"0 * * * *", Oban.Workers.CronA, args: %{special: true}},
22-
{"0 0 * * *", Oban.Workers.CronB, priority: 3},
23-
]}])
19+
start_supervised_oban!(
20+
plugins: [
21+
{Oban.Plugins.Cron,
22+
crontab: [
23+
{"* * * * *", Oban.Workers.CronA},
24+
{"0 * * * *", Oban.Workers.CronA, args: %{special: true}},
25+
{"0 0 * * *", Oban.Workers.CronB, priority: 3}
26+
]}
27+
]
28+
)
2429

2530
{:ok, live, _html} = live(build_conn(), "/oban/crons")
2631

0 commit comments

Comments
 (0)