Vendor oban_web 2.12.1 and oban_met 1.1.0 (taken from the towerops-web2 vendor tree), bump oban to ~> 2.21, and mount the Oban dashboard at /admin/oban behind the existing require_admin on_mount hook. Adds an admin-only "Oban" nav link and extends the Dockerfile to copy vendor/ before deps.get so production builds pick up the path dependencies.
301 lines
7.4 KiB
Elixir
301 lines
7.4 KiB
Elixir
defmodule Oban.Web.Helpers do
|
|
@moduledoc false
|
|
|
|
alias Oban.{Job, Registry}
|
|
alias Oban.Web.AccessError
|
|
alias Phoenix.{LiveView, VerifiedRoutes}
|
|
|
|
# Instance Helpers
|
|
|
|
def oban_instances do
|
|
# Only the top level instance is registered as an atom, all other keys are tuples
|
|
pattern = [{{:"$1", :_, :_}, [{:is_atom, :"$1"}], [:"$1"]}]
|
|
|
|
pattern
|
|
|> Registry.select()
|
|
|> Enum.sort_by(&if &1 == Oban, do: 0, else: &1)
|
|
end
|
|
|
|
# Routing Helpers
|
|
|
|
@doc """
|
|
Construct a path to a dashboard page with optional params.
|
|
|
|
Routing is based on a socket and prefix tuple stored in the process dictionary. Proper routing
|
|
can be disabled for testing by setting the value to `:nowhere`.
|
|
"""
|
|
def oban_path(route, params \\ %{})
|
|
|
|
def oban_path(route, params) when is_list(route) do
|
|
route
|
|
|> Enum.join("/")
|
|
|> oban_path(params)
|
|
end
|
|
|
|
def oban_path(route, params) do
|
|
params =
|
|
params
|
|
|> Enum.sort()
|
|
|> encode_params()
|
|
|
|
case Process.get(:routing) do
|
|
{socket, prefix} ->
|
|
VerifiedRoutes.unverified_path(socket, socket.router, "#{prefix}/#{route}", params)
|
|
|
|
:nowhere ->
|
|
"/"
|
|
|
|
nil ->
|
|
raise RuntimeError, "nothing stored in the :routing key"
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Toggle filterable params into a patch path.
|
|
"""
|
|
def patch_params(params, page, key, value) when is_map(params) and is_atom(page) do
|
|
value = to_string(value)
|
|
param_value = params[key]
|
|
|
|
params =
|
|
cond do
|
|
value == param_value or [value] == param_value ->
|
|
Map.delete(params, key)
|
|
|
|
is_list(param_value) and value in param_value ->
|
|
Map.put(params, key, List.delete(param_value, value))
|
|
|
|
is_list(param_value) ->
|
|
Map.put(params, key, [value | param_value])
|
|
|
|
true ->
|
|
Map.put(params, key, value)
|
|
end
|
|
|
|
oban_path(page, params)
|
|
end
|
|
|
|
@doc """
|
|
Prepare parsed params for URI encoding.
|
|
"""
|
|
def encode_params(params) do
|
|
for {key, val} <- params, val != nil, val != "" do
|
|
case val do
|
|
[path, frag] when is_list(path) ->
|
|
{key, Enum.join(path, ",") <> "++" <> frag}
|
|
|
|
[_ | _] ->
|
|
{key, Enum.join(val, ",")}
|
|
|
|
_ ->
|
|
{key, val}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Restore params from URI encoding.
|
|
"""
|
|
def decode_params(params) do
|
|
Map.new(params, fn
|
|
{"limit", val} ->
|
|
{:limit, String.to_integer(val)}
|
|
|
|
{key, val} when key in ~w(args meta) ->
|
|
val =
|
|
val
|
|
|> String.split("++")
|
|
|> List.update_at(0, &String.split(&1, ","))
|
|
|
|
{String.to_existing_atom(key), val}
|
|
|
|
{key, val}
|
|
when key in ~w(ids modes names nodes priorities queues stats states tags workers) ->
|
|
{String.to_existing_atom(key), String.split(val, ",")}
|
|
|
|
{key, val} ->
|
|
{String.to_existing_atom(key), val}
|
|
end)
|
|
end
|
|
|
|
def active_filter?(params, :state, value) do
|
|
params[:state] == value or (is_nil(params[:state]) and value == "executing")
|
|
end
|
|
|
|
def active_filter?(params, key, value) do
|
|
params
|
|
|> Map.get(key, [])
|
|
|> List.wrap()
|
|
|> Enum.member?(to_string(value))
|
|
end
|
|
|
|
@doc """
|
|
Put a flash message that will clear automatically after a timeout.
|
|
"""
|
|
def put_flash_with_clear(socket, mode, message, timing \\ 5_000) do
|
|
Process.send_after(self(), :clear_flash, timing)
|
|
|
|
LiveView.put_flash(socket, mode, message)
|
|
end
|
|
|
|
@doc """
|
|
Construct a map without any default values included.
|
|
"""
|
|
def without_defaults(%_params{}, _defaults), do: %{}
|
|
|
|
def without_defaults(params, defaults) do
|
|
params
|
|
|> Enum.reject(fn {key, val} -> val == defaults[key] end)
|
|
|> Map.new()
|
|
end
|
|
|
|
# Title Helpers
|
|
|
|
def page_title(%Job{id: id, worker: worker}), do: page_title("#{worker} (#{id})")
|
|
def page_title(prefix), do: "#{prefix} • Oban"
|
|
|
|
# Authorization Helpers
|
|
|
|
@doc """
|
|
Check an action against the current access controls.
|
|
"""
|
|
def can?(_action, :all), do: true
|
|
def can?(_action, :read_only), do: false
|
|
def can?(action, [_ | _] = opts), do: Keyword.get(opts, action, false)
|
|
|
|
@doc """
|
|
Enforce access by raising an error if access isn't allowed.
|
|
"""
|
|
def enforce_access!(action, opts) do
|
|
unless can?(action, opts), do: raise(AccessError)
|
|
|
|
:ok
|
|
end
|
|
|
|
# Formatting Helpers
|
|
|
|
@doc """
|
|
Delimit large integers with a comma separator.
|
|
"""
|
|
@spec integer_to_delimited(integer()) :: String.t()
|
|
def integer_to_delimited(integer) when is_integer(integer) do
|
|
integer
|
|
|> Integer.to_charlist()
|
|
|> Enum.reverse()
|
|
|> Enum.chunk_every(3, 3, [])
|
|
|> Enum.join(",")
|
|
|> String.reverse()
|
|
end
|
|
|
|
@doc """
|
|
Truncate strings beyond a fixed limit and append an ellipsis.
|
|
"""
|
|
@spec truncate(String.t(), Range.t()) :: String.t()
|
|
def truncate(string, range \\ 0..90) do
|
|
if String.length(string) > Enum.max(range) do
|
|
String.slice(string, range) <> "…"
|
|
else
|
|
string
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Round numbers to human readable values with a scale suffix.
|
|
"""
|
|
def integer_to_estimate(nil), do: "0"
|
|
|
|
def integer_to_estimate(number) when number < 1000, do: to_string(number)
|
|
|
|
def integer_to_estimate(number) when number < 10_000 do
|
|
power = 3
|
|
mult = Integer.pow(10, power)
|
|
base = floor(number / mult)
|
|
part = round(rem(number, mult) / Integer.pow(10, power - 1))
|
|
|
|
case part do
|
|
0 -> "#{base}k"
|
|
10 -> "#{base + 1}k"
|
|
_ -> "#{base}.#{part}k"
|
|
end
|
|
end
|
|
|
|
def integer_to_estimate(number) do
|
|
{power, suffix} =
|
|
cond do
|
|
number < 1_000_000 -> {3, "k"}
|
|
number < 1_000_000_000 -> {6, "m"}
|
|
true -> {9, "b"}
|
|
end
|
|
|
|
mult = Integer.pow(10, power)
|
|
base = round(number / mult)
|
|
|
|
"#{base}#{suffix}"
|
|
end
|
|
|
|
@doc """
|
|
Extract the name of the node that attempted a job.
|
|
"""
|
|
def attempted_by(%Job{attempted_by: [node | _]}), do: node
|
|
def attempted_by(%Job{}), do: "Not Attempted"
|
|
|
|
@doc """
|
|
Format job tags using a delimiter.
|
|
"""
|
|
def formatted_tags(%Job{tags: []}), do: "..."
|
|
def formatted_tags(%Job{tags: tags}), do: Enum.join(tags, ", ")
|
|
|
|
@doc """
|
|
A normalized, globally unique combination of instance and node names.
|
|
"""
|
|
def node_name(%{"node" => node, "name" => name}), do: node_name(node, name)
|
|
|
|
def node_name(node, name) do
|
|
[node, name]
|
|
|> Enum.join("/")
|
|
|> String.trim_leading("Elixir.")
|
|
|> String.downcase()
|
|
end
|
|
|
|
# State Helpers
|
|
|
|
@doc """
|
|
Whether the job can be cancelled in its current state.
|
|
"""
|
|
@spec cancelable?(Job.t()) :: boolean()
|
|
def cancelable?(%Job{state: state}) do
|
|
state in ~w(inserted scheduled available executing retryable)
|
|
end
|
|
|
|
@doc """
|
|
Whether the job can be ran immediately in its current state.
|
|
"""
|
|
@spec runnable?(Job.t()) :: boolean()
|
|
def runnable?(%Job{state: state}) do
|
|
state in ~w(inserted scheduled)
|
|
end
|
|
|
|
@doc """
|
|
Whether the job can be retried in its current state.
|
|
"""
|
|
@spec retryable?(Job.t()) :: boolean()
|
|
def retryable?(%Job{state: state}) do
|
|
state in ~w(completed retryable discarded cancelled)
|
|
end
|
|
|
|
@doc """
|
|
Whether the job can be deleted in its current state.
|
|
"""
|
|
@spec deletable?(Job.t()) :: boolean()
|
|
def deletable?(%Job{state: state}), do: state != "executing"
|
|
|
|
@doc """
|
|
Whether the job was left in an executing state when the node or producer running it shut down.
|
|
"""
|
|
def orphaned?(%Job{} = job, %MapSet{} = producers) do
|
|
case {job.state, job.attempted_by} do
|
|
{"executing", [_node, uuid | _]} -> not MapSet.member?(producers, uuid)
|
|
_ -> false
|
|
end
|
|
end
|
|
end
|