upgrade: re-vendor oban_pro 1.7.5, oban_web 2.12.5, oban_met 1.2.0; fix Oban unique states warnings; update deps and elixir to 1.19

This commit is contained in:
Graham McIntire 2026-06-03 14:38:34 -05:00
parent cad00fee06
commit a797bc7cef
145 changed files with 1300 additions and 948 deletions

View file

@ -1,4 +1,4 @@
erlang 28.5
elixir 1.20.0-rc.6-otp-28
erlang 29.0.1
elixir 1.20.0-otp-29
#elixir 1.20.0-rc.1-otp-28
nodejs 22.22.2

View file

@ -30,7 +30,7 @@ defmodule Towerops.Workers.CheckExecutorWorker do
max_attempts: 3,
unique: [
keys: [:check_id],
states: [:available, :scheduled, :retryable],
states: [:available, :scheduled, :retryable, :suspended],
period: :infinity
]

View file

@ -10,7 +10,7 @@ defmodule Towerops.Workers.CheckWorker do
unique: [
period: 60,
keys: [:check_id],
states: [:available, :scheduled, :executing, :retryable]
states: [:available, :scheduled, :executing, :retryable, :suspended]
]
alias Towerops.Agents

View file

@ -29,7 +29,7 @@ defmodule Towerops.Workers.CoverageWorker do
unique: [
fields: [:args],
keys: [:coverage_id],
states: [:available, :scheduled, :executing, :retryable]
states: [:available, :scheduled, :executing, :retryable, :suspended]
]
alias Towerops.Coverages

View file

@ -12,7 +12,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
unique: [
period: :infinity,
keys: [:device_id],
states: [:available, :scheduled, :retryable]
states: [:available, :scheduled, :retryable, :suspended]
],
replace: [
scheduled: [:scheduled_at],

View file

@ -18,7 +18,7 @@ defmodule Towerops.Workers.DevicePollerWorker do
unique: [
period: :infinity,
keys: [:device_id],
states: [:available, :scheduled, :retryable]
states: [:available, :scheduled, :retryable, :suspended]
],
replace: [
scheduled: [:scheduled_at],

View file

@ -18,7 +18,7 @@ defmodule Towerops.Workers.DiscoveryWorker do
"""
use Oban.Worker,
queue: :discovery,
unique: [period: 60, states: [:available, :scheduled, :executing]],
unique: [period: 60, states: [:available, :scheduled, :executing, :suspended, :retryable]],
max_attempts: 3
alias Towerops.Agents

View file

@ -7,7 +7,7 @@ defmodule Towerops.Workers.LidarCatalogSyncWorker do
"""
use Oban.Worker,
queue: :lidar,
unique: [period: 86_400, states: [:available, :scheduled, :executing]]
unique: [period: 86_400, states: [:available, :scheduled, :executing, :suspended, :retryable]]
alias Towerops.Lidar.Catalog

View file

@ -9,7 +9,7 @@ defmodule Towerops.Workers.WeatherSyncWorker do
"""
use Oban.Worker,
queue: :weather,
unique: [period: 600, states: [:available, :scheduled, :executing]]
unique: [period: 600, states: [:available, :scheduled, :executing, :suspended, :retryable]]
alias Towerops.Sites
alias Towerops.Weather

View file

@ -5,7 +5,7 @@ defmodule Towerops.MixProject do
[
app: :towerops,
version: "0.1.0",
elixir: "~> 1.18",
elixir: "~> 1.19",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
@ -188,7 +188,7 @@ defmodule Towerops.MixProject do
"phx.digest"
],
precommit: [
"compile --warnings-as-errors",
"compile",
"deps.unlock --unused",
"format",
"test"

View file

@ -1,36 +1,36 @@
%{
"absinthe": {:hex, :absinthe, "1.10.2", "7951efe6d22c7524752dc5e3fd4289e5bfb1d1e9ee2da2c79431c585552ded2e", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3948d6948c45b5cfd375892e578943eac8642d0a34b15e2a92ffdcdda9d91a22"},
"absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.5", "1fdcfc59db241b1d5e45e4c8c9db162277e64ec5728e0fd6958a3fa491a05b82", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "086c6d4a1c32f7444713130d204c87b1b006169f5159026b73f02f7d38ccd05c"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.9", "4f66fd46aecf969b349dd94853e6132db6d832ae6a4b951312b6926ad4ee7ca3", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dcdc84334b0e9e2cd439bd2653678a822623f212c71088edf0a4a7d03f1fa225"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.10", "c9e207235aaa8a086a5db6801a9bebaea035f7b5a2703cb98d962646ef70c76f", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "489ac1951c8e4128571141c60a0669a720619bc161f801a8c6be8cfaf7ab0979"},
"argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"},
"bandit": {:hex, :bandit, "1.11.0", "dbdd9c9963f146ee9da9860d1ee5b0ffd65cea51fe2aab3f3273df84329d133a", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c949d93a325a28da2333dde5a9ab61986ad2c2b7226347db6a28303b9139865e"},
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"cloak": {:hex, :cloak, "1.1.4", "aba387b22ea4d80d92d38ab1890cc528b06e0e7ef2a4581d71c3fdad59e997e7", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "92b20527b9aba3d939fab0dd32ce592ff86361547cfdc87d74edce6f980eb3d7"},
"cloak_ecto": {:hex, :cloak_ecto, "1.3.0", "0de127c857d7452ba3c3367f53fb814b0410ff9c680a8d20fbe8b9a3c57a1118", [:mix], [{:cloak, "~> 1.1.1", [hex: :cloak, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "314beb0c123b8a800418ca1d51065b27ba3b15f085977e65c0f7b2adab2de1cc"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
"cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
"db_connection": {:hex, :db_connection, "2.10.0", "8ff756471e41765bd5563b633f73e9a94bbc138816e8644bb17d0d91bf260a95", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02cdd01b45efb1b550e68edbbea41be32de9b24bb07e1ea0e9cbc522ac377e54"},
"decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"},
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
"decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
"ecto": {:hex, :ecto, "3.14.0", "2fa64521eebfcb2670d907a86e4ad947290e9933706bb315e6fb5c21b172cb26", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "130d69ffb4285f9ce4792b65dfbb994fd13ea4cbc3cbea2524b199aa3de84af3"},
"ecto_psql_extras": {:hex, :ecto_psql_extras, "0.8.8", "aa02529c97f69aed5722899f5dc6360128735a92dd169f23c5d50b1f7fdede08", [:mix], [{:ecto_sql, "~> 3.7", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "> 0.16.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1 or ~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "04c63d92b141723ad6fed2e60a4b461ca00b3594d16df47bbc48f1f4534f2c49"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"eqrcode": {:hex, :eqrcode, "0.2.1", "d12838813e8fc87b8940cc05f9baadb189031f6009facdc56ff074375ec73b6e", [:mix], [], "hexpm", "d5828a222b904c68360e7dc2a40c3ef33a1328b7c074583898040f389f928025"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"},
"error_tracker": {:hex, :error_tracker, "0.9.0", "fef10b1230be1b051452930ba4db9f809cdab9705fe87891c25f5e551cead372", [:mix], [{:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, ">= 0.0.0", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, ">= 0.0.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:phoenix_ecto, "~> 4.6", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1de7d89ec9034c3b7282b6bd2d0584ca98cddc51e87bf0979ba71f6182803ac4"},
"error_tracker_notifier": {:hex, :error_tracker_notifier, "0.2.1", "c13452605f4f27c9c9665136faab6c82fa626b7fadd59198ad948d61dc633715", [:mix], [{:error_tracker, "~> 0.5", [hex: :error_tracker, repo: "hexpm", optional: false]}, {:httpoison, "~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fda342098c28ca2df45463821de49be6a1402b30321c076aae689204c5e3fecc"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
"geo": {:hex, :geo, "4.1.0", "64ba89a64cc400b5b16dd2f5bd644cb141776eb8c2ac5a983332c8d944936c12", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "19edb2b3398ca9f701b573b1fb11bc90951ebd64f18b06bd1bf35abe509a2934"},
@ -50,7 +50,7 @@
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"},
"mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"},
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
"mix_test_watch": {:hex, :mix_test_watch, "1.4.0", "d88bcc4fbe3198871266e9d2f00cd8ae350938efbb11d3fa1da091586345adbb", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2b4693e17c8ead2ef56d4f48a0329891e8c2d0d73752c0f09272a2b17dc38d1b"},
"mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"},
@ -59,7 +59,7 @@
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"},
"oban": {:hex, :oban, "2.22.1", "9d2a38cec95070b31c1e274fae55f3925089f62159d8f3facabee0454d55b257", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "af2508c156c5b0ec30b21b0883babf7e2716af35ed5d264095896103fe3cea37"},
"oban": {:hex, :oban, "2.23.0", "1867d0fa4e8c7685217b02cc2632e3ee86c93da770e9029ff71304d9e62e53d7", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8e5f0cec5abecce78dd08cb14dc5438db90ec3884987b44773ce76fe60dd3f81"},
"octo_fetch": {:hex, :octo_fetch, "0.5.0", "f50701568b9fc752656367f82cc134d5fbefff37c5a0e8ddfcceb02ceee3f5fc", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "6226cc3c14ca948ee9f25fb0446322e5c288e215da9beba7899b6b5f4cd3ccb0"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"peep": {:hex, :peep, "3.5.0", "9f6ead7b0f2c684494200c8fc02e7e62e8c459afe861b29bd859e4c96f402ed8", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5a73a99c6e60062415efeb7e536a663387146463a3d3df1417da31fd665ac210"},
@ -68,35 +68,35 @@
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.30", "a84af1610755dc208da35d4d45564485edbf18c3f3c77373c4a650dc994cdcdb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a353c51ac1e3190910f01a6100c7d5cc02c5e22e7374fd817bd3aedd21149039"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.31", "c45c85df509dd79c917bc530e26c71299e3920850f65ea52ab6a19ccee66875a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2f53cc6a9e149f30449341c2775990819d97e3b22338fe719c4d30342e6f9638"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_pubsub_redis": {:hex, :phoenix_pubsub_redis, "3.1.1", "54919406b0b6401e0f643e26fd3b814bc3460f3fd3a32aa9c3a473db89e9dca7", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1 or ~> 1.6", [hex: :poolboy, repo: "hexpm", optional: false]}, {:redix, "~> 1.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "e339963f649e7cf52bd352e55fab7bebb42b65b501e43499dd6eb56890703c32"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
"plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.22.1", "b3665ad17e15441557da8f45eeebfcd56e4a2b0b98538b855679a13d05e5cc5d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "df59f828b167b49a5853f645b65f57eb1bc5f3b230497ceaca7af5d8ac05afef"},
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
"prom_ex": {:hex, :prom_ex, "1.11.0", "1f6d67f2dead92224cb4f59beb3e4d319257c5728d9638b4a5e8ceb51a4f9c7e", [:mix], [{:absinthe, ">= 1.7.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "76b074bc3730f0802978a7eb5c7091a65473eaaf07e99ec9e933138dcc327805"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"redix": {:hex, :redix, "1.5.3", "4eaae29c75e3285c0ff9957046b7c209aa7f72a023a17f0a9ea51c2a50ab5b0f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7b06fb5246373af41f5826b03334dfa3f636347d4d5d98b4d455b699d425ae7e"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.25.2", "cd3e53b0391439395492e5dce8c22288733f22603e21136162d03cd153669be9", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0aecf65b2845f13f4d440e0945715432bbde2d815e2302adf7df549cd9bdafed"},
"swoosh": {:hex, :swoosh, "1.26.0", "8fb146d261e3c4df2d828df0e28a5f233674976c9464c5177f13b7a431a266a9", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "83fe2c7c6572d21d5cdb47766ea041c13da234d09c96220c480eb90b21b1335c"},
"table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
"tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"},
}

BIN
vendor/oban_met/.hex vendored

Binary file not shown.

View file

@ -1,5 +1,137 @@
# Changelog for Oban Met v1.0
## v1.2.0 — 2026-05-13
This release is entirely dedicated to performance optimizations for the `Recorder` process, which
is heavily utilized by the Web dashboard. A summary of overall performance improvements in this
release:
| Operation | Before everything | After all changes | Speedup |
|---------------------|----------------------|---------------------------|--------------|
| latest:exec_count | 15.6 ms (compressed) | 0.013 ms | ~1200× |
| latest:full_count | 14.0 ms (compressed) | 0.014 ms | ~1000× |
| labels:worker | 99.5 ms (compressed) | 0.134 ms | ~740× |
| series | 308 ms (compressed) | 0.157 ms | ~1960× |
| timeslice:exec_time | 23.9 ms (compressed) | 0.63 ms | ~38× |
| compact | 1.45 s (blocking) | ~0.82 s (off-process) | non-blocking |
### Enhancements
- [Recorder] Reorder series-table key to bound timeslice scans
Move max_ts to position 2 of the key. Within a series rows now sort by time, so chunked
`select_reverse/3` walks newest-first and stops as soon as a chunk's oldest row falls below the
lookback cutoff. ETS work becomes proportional to rows-in-lookback rather than rows-in-series,
which is most of the win for short-lookback charts.
Measured end-to-end on `Recorder.timeslice/3` against a realistic 300k-row / 400-combo shape,
grouped by queue with p95 quantile:
```text
lookback 60s 4.05 ms → 0.63 ms ~6.4x
lookback 600s 9.75 ms → 5.58 ms ~1.7x
```
The select-level scan is 30x faster at lookback 60. Elixir reductions are now the limit for that
path. Against the original compressed-table baseline a 60s-lookback timeslice is ~38x faster.
- [Recorder] Add latest-snapshot table to accelerate `latest/3`
`Recorder.latest/3` previously scanned the series table's full prefix range for one series.
Using a benchmark of 300k rows, with ~1k-combo cardinality, that's ~25k rows walked through to
filter out everything past the 2s lookback.
A second table (`:metrics_latest`) now holds one row per key with the most recent value and
time, which is bounded by cardinality rather than history.
Measured at 300k rows / 400 combos / 750 timestamps per combo:
```text
latest:exec_count 8.75 ms → 0.013 ms (657× faster)
latest:full_count 8.85 ms → 0.014 ms (664× faster)
per-call memory 10.6 KB → 1.4 KB (7.6× less)
```
The snapshot is swept of stale entries during compaction (anything older than the retention
horizon) and rebuilt from the series table on handoff to keep peak memory bounded during
reconstruction.
- [Recorder] Serve `labels/3` and `series/1` from latest snapshot
The latest snapshot already contains one row per unique (series, labels)
combo, which is exactly the diversity these two functions need.
Switching `labels` and `series` yeilded massive speed and memory
improvements:
```text
labels:worker 32.26 ms → 0.134 ms (240× faster)
935 KB → 29 KB (31× less memory)
series/1 214 ms → 0.157 ms (1597× faster)
209 MB → 59 KB (3550× less memory)
```
That includes the effect of an improved post select pipeline also uses
fewer operations with a single reduce piped into a map.
- [Recorder] Trim compact periods for fidelity with less data
The previous periods retained roughly 780 buckets per (series, labels) combination across a 3.4
hour horizon, sized well beyond what any oban_web chart actually reads. The new windows are
dimensioned so each chart period (1s through 2m) still sees source granularity at or below its
`by` step throughout its lookback range.
Measured against a realistic shape (400 active label combos × ~750 timestamps over the full
horizon):
```text
before: 119k rows / 81 MB / 267 ms compact
after: 103k rows / 70 MB / 225 ms compact
```
A modest ~13% drop in steady-state memory and compaction work without changing any public
behavior. Customers running with a per-process `compact_periods` override are unaffected.
- [Recorder] Move compaction off-process and drop compression
Drop `:compressed` from the metrics ETS table. The flag decompressed every row on every
match-spec evaluation, which is the recorder's entire hot path. Removing it speeds up compaction
1.8x, `latest` 3x, and `timeslice` 4x at 300k rows, at the cost of 2x ETS memory.
- [Recorder] Run compaction out of the recorder process
Run compaction in a `Task` against a `:public` table. The recorder process is no longer blocked
for ~800ms/minute on compaction work, and the 290 MB transient allocation lives on the Task's
heap rather than forcing a `:hibernate` on the recorder. Concurrent `:metrics` writes complete
during compaction (measured ~14× faster worst-case store latency) instead of queueing in the
recorder's mailbox.
- [Recorder] Target handoff acks to the requesting recorder
A leader's ack was broadcast to every listener, so every recorder on every node decoded the
payload only to find it wasn't theirs. Now messages are routed via the notifier's ident
mechanism. Also skip syns whose name/node match the leader's own conf so the leader no longer
responds to its own startup announcement.
- [Recorder] Accept handoff off the recorder process
The follower now decodes the handoff payload and rebuilds in a separate process to avoid
blocking or bloating the recorder. Rebuilding the latest table no longer deletes all objects in
order to preserves concurrent writes, those keys are cleaned up later during compaction.
- [Recorder] Spawn handoff serialization in a linked task
The leader's handoff dumped the entire series table, serialized, compressed, and base64-encoded
inline in the Recorder process. For large tables, that's hundreds of milliseconds blocking and
significant memory usage for the process. That work moved into a task so the Recorder returns
immediately.
- [Recorder] Compress encoded recorder handoff output
The handoff payload was serialized uncompressed before notification. The trailing gzip can't
recover much from a base64 string, so the binary went out essentially uncompressed. Now it's
compresssed at the step where the input is still raw terms with highly repetitive label maps.
## v1.1.0 — 2026-03-25
### Enhancements

View file

@ -16,7 +16,9 @@ defmodule Oban.Met.Recorder do
@type ts :: integer()
@type period :: {pos_integer(), pos_integer()}
@periods [{1, 300}, {5, 1_200}, {30, 3_600}, {60, 7_200}]
@chunk_size 500
@periods [{1, 120}, {5, 900}, {30, 2_000}, {60, 9_300}]
@default_latest_opts [filters: [], group: nil, lookback: 2]
@ -31,8 +33,9 @@ defmodule Oban.Met.Recorder do
defstruct [
:compact_timer,
:conf,
:latest_table,
:name,
:table,
:series_table,
compact_periods: @periods,
handoff: :awaiting
]
@ -53,7 +56,9 @@ defmodule Oban.Met.Recorder do
def lookup(name, series) do
match = {{to_string(series), :_, :_}, :_, :_, :_}
:ets.select_reverse(table(name), [{match, [], [:"$_"]}])
name
|> series_table()
|> :ets.select_reverse([{match, [], [:"$_"]}])
end
@spec labels(GenServer.name(), label(), keyword()) :: [label()]
@ -62,12 +67,12 @@ defmodule Oban.Met.Recorder do
stime = Keyword.get(opts, :since, System.system_time(:second))
lookback = Keyword.get(opts, :lookback, 120)
match = {{:_, :_, :"$2"}, :_, :"$1", :_}
match = {{:_, :_}, :"$1", :_, :"$2"}
guard = [{:andalso, {:is_map_key, label, :"$1"}, {:>=, :"$2", stime - lookback}}]
value = [{:map_get, label, :"$1"}]
name
|> table()
|> latest_table()
|> :ets.select([{match, guard, value}])
|> :lists.usort()
end
@ -80,28 +85,26 @@ defmodule Oban.Met.Recorder do
lookback = Keyword.fetch!(opts, :lookback)
filters = Keyword.fetch!(opts, :filters)
name
|> table()
|> select(series, lookback, filters)
|> Enum.dedup_by(fn {{_, _, _}, _, labels, _} -> labels end)
|> Enum.group_by(fn {{_, _, _}, _, labels, _} -> labels[group] || "all" end)
|> Map.new(fn {group, metrics} ->
total =
metrics
|> Enum.map(&elem(&1, 3))
|> Enum.reduce(&Value.merge/2)
|> Value.sum()
stime = System.system_time(:second)
match = {{to_string(series), :_}, :"$1", :"$2", :"$3"}
guard = filters_to_guards(filters, {:>=, :"$3", stime - lookback})
body = [{{:"$1", :"$2"}}]
{group, total}
name
|> latest_table()
|> :ets.select([{match, [guard], body}])
|> Enum.reduce(%{}, fn {labels, value}, acc ->
Map.update(acc, labels[group] || "all", value, &Value.merge(&1, value))
end)
|> Map.new(fn {group, merged} -> {group, Value.sum(merged)} end)
end
@spec series(GenServer.name()) :: [map()]
def series(name) do
match = {{:"$1", :_, :_}, :_, :"$2", :"$3"}
match = {{:"$1", :_}, :"$2", :"$3", :_}
name
|> table()
|> latest_table()
|> :ets.select([{match, [], [:"$$"]}])
|> Enum.group_by(&hd/1)
|> Enum.map(fn {series, [[_series, _labels, %vtype{}] | _] = metrics} ->
@ -127,7 +130,7 @@ defmodule Oban.Met.Recorder do
since = Keyword.get(opts, :since, System.system_time(:second))
name
|> table()
|> series_table()
|> select(series, lookback, opts[:filters])
|> Enum.reduce(%{}, &merge_group(&1, &2, group))
|> Enum.reduce(%{}, &merge_chunk(&1, &2, since, by))
@ -144,7 +147,7 @@ defmodule Oban.Met.Recorder do
end)
end
defp merge_group({{_, _, ts}, _, labels, value}, acc, group) do
defp merge_group({{_, ts, _}, _, labels, value}, acc, group) do
Map.update(acc, {labels[group], ts}, value, &Value.union(&1, value))
end
@ -168,20 +171,30 @@ defmodule Oban.Met.Recorder do
@impl GenServer
def init(opts) do
table =
:ets.new(:metrics, [
:compressed,
series_table =
:ets.new(:metrics_series, [
:ordered_set,
:protected,
read_concurrency: true
:public,
read_concurrency: true,
write_concurrency: true
])
latest_table =
:ets.new(:metrics_latest, [
:ordered_set,
:public,
read_concurrency: true,
write_concurrency: true
])
state =
State
|> struct!(Keyword.put(opts, :table, table))
opts
|> Keyword.put(:series_table, series_table)
|> Keyword.put(:latest_table, latest_table)
|> then(&struct!(State, &1))
|> schedule_compact()
Registry.register(Oban.Registry, state.name, table)
Registry.register(Oban.Registry, state.name, {series_table, latest_table})
{:ok, state, {:continue, :start}}
end
@ -202,38 +215,49 @@ defmodule Oban.Met.Recorder do
end
@impl GenServer
def handle_call({:compact, periods}, _from, %State{table: table} = state) do
inner_compact(table, periods)
def handle_call({:compact, periods}, _from, %State{} = state) do
inner_compact(state.series_table, state.latest_table, periods, System.system_time(:second))
{:reply, :ok, state}
end
def handle_call({:store, params}, _from, %State{table: table} = state) do
def handle_call({:store, params}, _from, %State{} = state) do
{series, value, labels, time} = params
inner_store(table, series, value, labels, time)
inner_store(state.series_table, state.latest_table, series, value, labels, time)
{:reply, :ok, state}
end
@impl GenServer
def handle_info({:notification, :handoff, %{"syn" => _}}, state) do
if Peer.leader?(state.conf) do
data =
state.table
|> :ets.tab2list()
|> :erlang.term_to_binary()
|> Base.encode64()
def handle_info({:notification, :handoff, %{"syn" => _} = msg}, state) do
from_name = Map.fetch!(msg, "name")
from_node = Map.fetch!(msg, "node")
own_name = inspect(state.conf.name)
own_node = state.conf.node
payload = %{
ack: true,
module: __MODULE__,
data: data,
node: state.conf.node,
name: inspect(state.conf.name)
}
if {from_name, from_node} != {own_name, own_node} and Peer.leader?(state.conf) do
%{conf: conf, series_table: series_table} = state
target_ident = from_name <> "." <> to_string(from_node)
Notifier.notify(state.conf, :handoff, payload)
Task.start_link(fn ->
data =
series_table
|> :ets.tab2list()
|> :erlang.term_to_binary([:compressed])
|> Base.encode64()
payload = %{
ack: true,
module: __MODULE__,
ident: target_ident,
data: data,
node: conf.node,
name: inspect(conf.name)
}
Notifier.notify(conf, :handoff, payload)
end)
end
{:noreply, state}
@ -241,10 +265,16 @@ defmodule Oban.Met.Recorder do
def handle_info({:notification, :handoff, %{"ack" => _, "data" => data}}, %State{} = state) do
if state.handoff == :awaiting and not Peer.leader?(state.conf) do
data
|> Base.decode64!()
|> :erlang.binary_to_term()
|> then(&:ets.insert(state.table, &1))
%{series_table: series_table, latest_table: latest_table} = state
Task.start_link(fn ->
data
|> Base.decode64!()
|> :erlang.binary_to_term()
|> then(&:ets.insert(series_table, &1))
rebuild_latest(series_table, latest_table)
end)
end
{:noreply, %{state | handoff: :complete}}
@ -259,7 +289,7 @@ defmodule Oban.Met.Recorder do
|> Map.drop(~w(series value))
|> Map.put("node", node)
inner_store(state.table, series, from_map(value), labels, time)
inner_store(state.series_table, state.latest_table, series, from_map(value), labels, time)
end
{:noreply, state}
@ -269,12 +299,15 @@ defmodule Oban.Met.Recorder do
{:noreply, state}
end
def handle_info(:compact, %State{compact_periods: periods, table: table} = state) do
inner_compact(table, periods)
def handle_info(:compact, %State{} = state) do
%{compact_periods: periods, series_table: series_table, latest_table: latest_table} = state
:erlang.garbage_collect()
# Window shifted back 2s so concurrent writes (at time=now) can't race the delete.
Task.start(fn ->
inner_compact(series_table, latest_table, periods, System.system_time(:second) - 2)
end)
{:noreply, schedule_compact(state), :hibernate}
{:noreply, schedule_compact(state)}
end
defp from_map(%{"size" => _} = value), do: Sketch.from_map(value)
@ -282,40 +315,45 @@ defmodule Oban.Met.Recorder do
# Table
defp table(name) do
defp latest_table(name), do: name |> tables() |> elem(1)
defp series_table(name), do: name |> tables() |> elem(0)
defp tables(name) do
case Registry.lookup(Oban.Registry, name) do
[{_pid, table}] ->
table
[{_pid, {_series, _latest} = pair}] ->
pair
_ ->
raise RuntimeError, "no table registered for #{inspect(name)}"
end
end
defp inner_compact(table, periods) do
delete_outdated(table, periods)
defp inner_compact(series_table, latest_table, periods, now) do
delete_outdated_series(series_table, periods, now)
delete_outdated_latest(latest_table, periods, now)
Enum.reduce(periods, System.system_time(:second), fn {step, duration}, ts ->
Enum.reduce(periods, now, fn {step, duration}, ts ->
since = ts - duration
match = {{:_, :_, :"$1"}, :"$2", :_, :_}
match = {{:_, :"$1", :_}, :"$2", :_, :_}
guard = [{:andalso, {:>=, :"$2", since}, {:"=<", :"$1", ts}}]
objects = :ets.select(table, [{match, guard, [:"$_"]}])
_delete = :ets.select_delete(table, [{match, guard, [true]}])
objects = :ets.select(series_table, [{match, guard, [:"$_"]}])
_delete = :ets.select_delete(series_table, [{match, guard, [true]}])
objects
|> Enum.chunk_by(fn {{ser, lab, max}, _, _, _} -> {ser, lab, div(ts - max - 1, step)} end)
|> Enum.map(&compact_object/1)
|> then(&:ets.insert(table, &1))
|> Enum.group_by(fn {{ser, max, lab}, _, _, _} -> {ser, lab, div(ts - max - 1, step)} end)
|> Enum.map(fn {_chunk, metrics} -> compact_object(metrics) end)
|> then(&:ets.insert(series_table, &1))
since
end)
end
defp compact_object([{{series, lab_key, _}, _, labels, _} | _] = metrics) do
defp compact_object([{{series, _, lab_key}, _, labels, _} | _] = metrics) do
{min_ts, max_ts} =
metrics
|> Enum.flat_map(fn {{_, _, max_ts}, min_ts, _, _} -> [max_ts, min_ts] end)
|> Enum.flat_map(fn {{_, max_ts, _}, min_ts, _, _} -> [max_ts, min_ts] end)
|> Enum.min_max()
value =
@ -323,30 +361,69 @@ defmodule Oban.Met.Recorder do
|> Enum.map(&elem(&1, 3))
|> Enum.reduce(&Value.merge/2)
{{series, lab_key, max_ts}, min_ts, labels, value}
{{series, max_ts, lab_key}, min_ts, labels, value}
end
defp delete_outdated(table, periods) do
systime = System.system_time(:second)
defp delete_outdated_series(table, periods, now) do
maximum = Enum.reduce(periods, 0, fn {_, duration}, acc -> duration + acc end)
since = systime - maximum
match = {{:_, :_, :"$1"}, :_, :_, :_}
since = now - maximum
match = {{:_, :"$1", :_}, :_, :_, :_}
guard = [{:<, :"$1", since}]
:ets.select_delete(table, [{match, guard, [true]}])
end
defp inner_store(table, series, value, labels, time) do
key = {to_string(series), :erlang.phash2(labels), time}
defp inner_store(series_table, latest_table, series, value, labels, time) do
series = to_string(series)
hash = :erlang.phash2(labels)
key = {series, time, hash}
value =
case :ets.lookup(table, key) do
merged =
case :ets.lookup(series_table, key) do
[{_key, _time, _labels, old_value}] -> Value.union(old_value, value)
_ -> value
end
:ets.insert(table, {key, time, labels, value})
:ets.insert(series_table, {key, time, labels, merged})
case :ets.lookup(latest_table, {series, hash}) do
[{_, _, _, prev_time}] when prev_time > time ->
:ok
[{_, _, prev_value, ^time}] ->
:ets.insert(latest_table, {{series, hash}, labels, Value.union(prev_value, value), time})
_ ->
:ets.insert(latest_table, {{series, hash}, labels, value, time})
end
end
defp delete_outdated_latest(latest_table, periods, now) do
maximum = Enum.reduce(periods, 0, fn {_, duration}, acc -> duration + acc end)
since = now - maximum
match = {{:_, :_}, :_, :_, :"$1"}
guard = [{:<, :"$1", since}]
:ets.select_delete(latest_table, [{match, guard, [true]}])
end
defp rebuild_latest(series_table, latest_table) do
fun = fn {{series, max_ts, hash}, _min_ts, labels, value}, acc ->
Map.update(acc, {series, hash}, {labels, value, max_ts}, fn
{_, _, prev_ts} = entry when prev_ts >= max_ts -> entry
_ -> {labels, value, max_ts}
end)
end
fun
|> :ets.foldl(%{}, series_table)
|> Enum.each(fn {key, {labels, value, max_ts}} ->
case :ets.lookup(latest_table, key) do
[{_, _, _, prev_ts}] when prev_ts >= max_ts -> :ok
_ -> :ets.insert(latest_table, {key, labels, value, max_ts})
end
end)
end
# Scheduling
@ -370,13 +447,42 @@ defmodule Oban.Met.Recorder do
# Fetching & Filtering
defp select(table, series, since, filters) do
stime = System.system_time(:second)
match = {{to_string(series), :_, :"$2"}, :_, :"$1", :_}
guard = filters_to_guards(filters, {:>=, :"$2", stime - since})
cutoff = System.system_time(:second) - since
match = {{to_string(series), :_, :_}, :_, :"$1", :_}
:ets.select_reverse(table, [{match, [guard], [:"$_"]}])
conditions =
case filters do
[_ | _] -> [filters_to_guards(filters, true)]
_ -> []
end
case :ets.select_reverse(table, [{match, conditions, [:"$_"]}], @chunk_size) do
{rows, cont} -> collect_until(rows, cont, cutoff, [])
:"$end_of_table" -> []
end
end
defp collect_until(rows, cont, cutoff, acc) do
case take_recent(rows, cutoff, acc) do
{:done, acc} ->
acc
{:continue, acc} ->
case :ets.select(cont) do
{more, more_cont} -> collect_until(more, more_cont, cutoff, acc)
:"$end_of_table" -> acc
end
end
end
defp take_recent([], _cutoff, acc), do: {:continue, acc}
defp take_recent([{{_, max_ts, _}, _, _, _} = row | rest], cutoff, acc) when max_ts >= cutoff do
take_recent(rest, cutoff, [row | acc])
end
defp take_recent(_, _cutoff, acc), do: {:done, acc}
defp filters_to_guards(nil, base), do: base
defp filters_to_guards(filters, base) do

View file

@ -2,7 +2,7 @@ defmodule Oban.Met.MixProject do
use Mix.Project
@source_url "https://github.com/oban-bg/oban_met"
@version "1.1.0"
@version "1.2.0"
def project do
[

BIN
vendor/oban_pro/.hex vendored

Binary file not shown.

View file

@ -500,8 +500,8 @@ defmodule Oban.Pro.Batch do
Validation.validate_schema!(opts,
batch_id: :string,
batch_name: :string,
callback_opts: :list,
callback_worker: :module
callback_opts: :any,
callback_worker: {:or, [:atom, :string]}
)
end

View file

@ -357,6 +357,48 @@ defmodule Oban.Pro.Chunk do
Utils.hash64(chunk_data)
end
@doc false
def repair_chunks(conf, opts) do
limit = Keyword.fetch!(opts, :limit)
timeout = Keyword.fetch!(opts, :timeout)
query =
Job
|> where([j], j.state in ~w(available scheduled retryable))
|> where([j], fragment("? \\? 'chunk_by'", j.meta))
|> where([j], fragment("NOT ? \\? 'chunk_id'", j.meta))
|> limit(^limit)
conf
|> Repo.all(query, timeout: timeout)
|> Enum.group_by(&gen_chunk_id/1)
|> Enum.each(fn {chunk_id, grouped_jobs} ->
ids = Enum.map(grouped_jobs, & &1.id)
Job
|> where([j], j.id in ^ids)
|> update([j], set: [meta: fragment("jsonb_set(?, '{chunk_id}', ?)", j.meta, ^chunk_id)])
|> then(&Repo.update_all(conf, &1, [], timeout: timeout))
end)
:ok
end
defp gen_chunk_id(job) do
chunk_by = normalize_by(job.meta["chunk_by"])
to_chunk_id(chunk_by, job.worker, job.args, queue: job.queue, meta: job.meta)
end
defp normalize_by(by) do
# This can't use Utils.chunk_by/1 because it has to cast to atom keys.
Enum.map(by, fn
"worker" -> :worker
["args", keys] -> [:args, Enum.map(keys, &Utils.maybe_to_atom/1)]
["meta", keys] -> [:meta, Enum.map(keys, &Utils.maybe_to_atom/1)]
end)
end
# Public Interface
@doc false

View file

@ -59,7 +59,7 @@ defmodule Oban.Pro.Diagnostics do
end
end
case Enum.find_value(list_oban_instances(), finder) do
case Enum.find_value(oban_instances(), finder) do
{conf, pid} ->
payload = %{"info" => process_info(pid), "job_id" => job_id, "node" => inspect(conf.node)}
@ -80,11 +80,11 @@ defmodule Oban.Pro.Diagnostics do
defp discover_and_subscribe(state) do
state =
Enum.reduce(list_oban_instances(), state, fn conf, acc ->
Enum.reduce(oban_instances(), state, fn conf, acc ->
if MapSet.member?(acc.subscribed, conf.name) do
acc
else
:ok = Notifier.listen(conf.name, [:diagnostics])
Notifier.listen(conf, [:diagnostics])
%{acc | subscribed: MapSet.put(acc.subscribed, conf.name)}
end
@ -95,12 +95,10 @@ defmodule Oban.Pro.Diagnostics do
|> schedule_discover()
end
defp list_oban_instances do
match = [{{{:"$1", Oban.Notifier}, :_, :_}, [], [:"$1"]}]
defp oban_instances do
match = [{{:"$1", :_, :"$2"}, [{:not, {:is_tuple, :"$1"}}], [:"$2"]}]
Oban.Registry
|> Registry.select(match)
|> Enum.map(&Oban.config/1)
Registry.select(Oban.Registry, match)
end
defp process_info(pid) do

View file

@ -549,9 +549,13 @@ defmodule Oban.Pro.Engines.Smart do
end
end
defmacrop xact_lock(pref_key, lock_key) do
defmacrop try_xact_lock(pref_key, lock_key) do
quote do
fragment("pg_try_advisory_xact_lock(?::int, ?::int)", unquote(pref_key), unquote(lock_key))
fragment(
"pg_try_advisory_xact_lock(?::int, ?::int)",
unquote(pref_key),
unquote(lock_key)
)
end
end
@ -750,6 +754,7 @@ defmodule Oban.Pro.Engines.Smart do
|> Multi.put(:conf, conf)
|> Multi.put(:running, running)
|> Multi.put(:producer, track_acks(acks, producer))
|> Multi.run(:afh_locks, &take_flush_locks/2)
|> Multi.run(:all_producers, &all_producers/2)
|> Multi.run(:ack_ids, &ack_jobs/2)
|> Multi.run(:afh_ids, &run_flush_handlers/2)
@ -764,7 +769,7 @@ defmodule Oban.Pro.Engines.Smart do
{:ok, {producer, jobs}}
{:error, _op, %{postgres: %{code: :unique_violation, detail: detail}}, changes} ->
clear_uniq_violation(changes.conf, detail)
clear_uniq_violation(changes.conf, detail, ~w(available executing retryable))
retry_unique_violation(:fetch_jobs, [conf, producer, running])
@ -773,6 +778,11 @@ defmodule Oban.Pro.Engines.Smart do
fetch_jobs(conf, producer, running)
{:error, _operation, %{postgres: %{code: :deadlock_detected}}, _changes} ->
jittery_sleep()
fetch_jobs(conf, producer, running)
{:error, _operation, error, _changes} ->
raise error
end
@ -781,6 +791,11 @@ defmodule Oban.Pro.Engines.Smart do
clear_uniq_violation(conf, detail, Enum.map(Job.states(), &to_string/1))
retry_unique_violation(:fetch_jobs, [conf, producer, running])
:error, %Postgrex.Error{postgres: %{code: :deadlock_detected}} ->
jittery_sleep()
fetch_jobs(conf, producer, running)
end
@doc false
@ -1000,7 +1015,7 @@ defmodule Oban.Pro.Engines.Smart do
#
# We find conflicting jobs by matching the unique key in meta and filtering by the expected
# states. Clearing their `uniq_bmp` disables the unique constraint, allowing the transition.
def clear_uniq_violation(%Config{} = conf, detail, states \\ ["executing"]) do
def clear_uniq_violation(%Config{} = conf, detail, states) do
# Match both column-based `(uniq_key)=(value)` and expression-based `((meta->>'uniq_key'))=(value)`
# formats. The exact spacing is critical.
pattern = ~r/\((?:uniq_key|\(meta ->> 'uniq_key'::text\))\)=\((?<key>.+)\)/
@ -1025,7 +1040,7 @@ defmodule Oban.Pro.Engines.Smart do
if !:persistent_term.get(log_key, false) do
:persistent_term.put(log_key, true)
Logger.info(fn ->
Logger.warning(fn ->
"""
[Oban.Pro.Engines.Smart] Unique constraint violation repaired.
@ -1256,14 +1271,9 @@ defmodule Oban.Pro.Engines.Smart do
defp prepare_chains(_repo, %{link_map: link_map, conf: conf}) when map_size(link_map) > 0 do
chain_ids = Map.keys(link_map)
if has_advisory_locks?(conf) do
lock_keys =
chain_ids
|> Enum.map(&:erlang.phash2({conf.prefix, &1}))
|> Enum.sort()
Repo.query!(conf, "SELECT pg_advisory_xact_lock(unnest($1::bigint[]))", [lock_keys])
end
chain_ids
|> Enum.map(&:erlang.phash2({conf.prefix, :chain, &1}))
|> take_advisory_locks(conf)
query =
from(
@ -1448,6 +1458,17 @@ defmodule Oban.Pro.Engines.Smart do
track_acks(acks, producer)
end
defp take_flush_locks(_repo, %{acks: acks, conf: conf}) do
acks
|> Enum.flat_map(&elem(&1, 2))
|> Enum.flat_map(fn
{Oban.Pro.Workflow, _, [wids, _con]} when is_list(wids) -> wids
_ -> []
end)
|> Enum.map(&:erlang.phash2({conf.prefix, :flush, &1}))
|> take_advisory_locks(conf)
end
defp run_flush_handlers(_repo, %{acks: acks, conf: conf}) do
{:ok, run_flush_handlers(acks, conf)}
end
@ -1477,7 +1498,7 @@ defmodule Oban.Pro.Engines.Smart do
not has_advisory_locks?(conf) ->
{:ok, Repo.all(conf, lock(query, "FOR UPDATE NOWAIT"))}
take_advisory_lock?(conf, queue) ->
try_advisory_lock?(queue, conf) ->
{:ok, Repo.all(conf, query)}
true ->
@ -1514,15 +1535,27 @@ defmodule Oban.Pro.Engines.Smart do
end)
end
defp take_advisory_lock?(conf, queue) do
defp try_advisory_lock?(queue, conf) do
pre = :erlang.phash2(conf.prefix)
key = :erlang.phash2(queue)
query = from(f in xact_lock(^pre, ^key), select: f.f0)
query = from(f in try_xact_lock(^pre, ^key), select: f.f0)
Repo.one(conf, query)
end
defp take_advisory_locks(keys, conf) do
if Enum.any?(keys) and has_advisory_locks?(conf) do
keys = :lists.usort(keys)
Repo.query!(conf, "SELECT pg_advisory_xact_lock(unnest($1::bigint[]))", [keys])
{:ok, length(keys)}
else
{:ok, 0}
end
end
defp fetch_jobs(_repo, %{conf: conf, producer: producer} = changes) do
span(:fetch, conf, %{queue: producer.queue}, fn ->
subset_query = fetch_subquery(changes)

View file

@ -37,28 +37,31 @@ defmodule Oban.Pro.Limiters.Global do
end
defp separate_demands(conf, allowed, producer, tracked) do
untracked =
for key <- Partition.available_keys(conf, producer.queue, producer.meta.local_limit),
not is_map_key(tracked, key),
into: %{},
do: {key, allowed}
keys = Partition.available_keys(conf, producer.queue, producer.meta.local_limit)
allowed =
if producer.meta.global_limit.burst do
size =
tracked
|> Map.merge(untracked)
|> Map.delete("none")
|> map_size()
keys
|> MapSet.new()
|> MapSet.union(MapSet.new(Map.keys(tracked)))
|> MapSet.delete("none")
|> MapSet.size()
|> max(1)
producer.meta.local_limit
(producer.meta.local_limit + size - 1)
|> div(size)
|> max(allowed)
else
allowed
end
untracked =
for key <- keys,
not is_map_key(tracked, key),
into: %{},
do: {key, allowed}
Enum.reduce(tracked, untracked, fn {key, total}, acc ->
case max(allowed - total, 0) do
demand when demand > 0 -> Map.put(acc, key, demand)

View file

@ -68,7 +68,7 @@ defmodule Oban.Pro.Migrations.V170 do
cancelled = GREATEST(0, wf.cancelled + COALESCE(d.cancelled, 0)),
discarded = GREATEST(0, wf.discarded + COALESCE(d.discarded, 0)),
started_at = CASE
WHEN wf.started_at IS NULL AND d.executing > 0 THEN NOW()
WHEN wf.started_at IS NULL AND d.executing > 0 THEN timezone('UTC', now())
ELSE wf.started_at
END,
completed_at = CASE
@ -76,7 +76,7 @@ defmodule Oban.Pro.Migrations.V170 do
AND (wf.suspended + wf.available + wf.scheduled + wf.executing + wf.retryable
+ COALESCE(d.suspended, 0) + COALESCE(d.available, 0) + COALESCE(d.scheduled, 0)
+ COALESCE(d.executing, 0) + COALESCE(d.retryable, 0)) <= 0
THEN NOW()
THEN timezone('UTC', now())
ELSE wf.completed_at
END
FROM (
@ -159,17 +159,9 @@ defmodule Oban.Pro.Migrations.V170 do
# enables querying meta fields directly without relying on generated columns. Partitioned
# tables already use expression indexes from v1.5.0.
if not opts.generated_columns and not partitioned?(opts) do
drop_if_exists index(:oban_jobs, [],
name: :oban_jobs_unique_index,
concurrently: opts.concurrently,
prefix: opts.prefix
)
execute "ALTER INDEX IF EXISTS #{opts.quoted}.oban_jobs_unique_index RENAME TO oban_jobs_unique_index_old"
drop_if_exists index(:oban_jobs, [],
name: :oban_jobs_partition_index,
concurrently: opts.concurrently,
prefix: opts.prefix
)
execute "ALTER INDEX IF EXISTS #{opts.quoted}.oban_jobs_partition_index RENAME TO oban_jobs_partition_index_old"
create_if_not_exists index(
:oban_jobs,
@ -238,17 +230,9 @@ defmodule Oban.Pro.Migrations.V170 do
# Replace the non-partial state timestamp indexes from core Oban with partial indexes.
# These partial indexes are smaller and more efficient for pruning operations.
drop_if_exists index(:oban_jobs, [],
name: :oban_jobs_state_cancelled_at_index,
concurrently: opts.concurrently,
prefix: opts.prefix
)
execute "ALTER INDEX IF EXISTS #{opts.quoted}.oban_jobs_state_cancelled_at_index RENAME TO oban_jobs_cancelled_at_index_old"
drop_if_exists index(:oban_jobs, [],
name: :oban_jobs_state_discarded_at_index,
concurrently: opts.concurrently,
prefix: opts.prefix
)
execute "ALTER INDEX IF EXISTS #{opts.quoted}.oban_jobs_state_discarded_at_index RENAME TO oban_jobs_discarded_at_index_old"
create_if_not_exists index(
:oban_jobs,
@ -390,27 +374,33 @@ defmodule Oban.Pro.Migrations.V170 do
prefix: opts.prefix
)
create_if_not_exists index(
:oban_jobs,
[:state, :cancelled_at],
concurrently: opts.concurrently,
name: :oban_jobs_state_cancelled_at_index,
prefix: opts.prefix
)
create_if_not_exists index(
:oban_jobs,
[:state, :discarded_at],
concurrently: opts.concurrently,
name: :oban_jobs_state_discarded_at_index,
prefix: opts.prefix
)
# Rename old indexes back
execute "ALTER INDEX IF EXISTS #{opts.quoted}.oban_jobs_workflow_index_old RENAME TO oban_jobs_workflow_index"
execute "ALTER INDEX IF EXISTS #{opts.quoted}.oban_jobs_sup_workflow_index_old RENAME TO oban_jobs_sup_workflow_index"
execute "ALTER INDEX IF EXISTS #{opts.quoted}.oban_jobs_chain_index_old RENAME TO oban_jobs_chain_index"
execute "ALTER INDEX IF EXISTS #{opts.quoted}.oban_jobs_cancelled_at_index_old RENAME TO oban_jobs_state_cancelled_at_index"
execute "ALTER INDEX IF EXISTS #{opts.quoted}.oban_jobs_discarded_at_index_old RENAME TO oban_jobs_state_discarded_at_index"
if not opts.generated_columns and not partitioned?(opts) do
drop_if_exists index(:oban_jobs, [],
name: :oban_jobs_unique_index,
concurrently: opts.concurrently,
prefix: opts.prefix
)
drop_if_exists index(:oban_jobs, [],
name: :oban_jobs_partition_index,
concurrently: opts.concurrently,
prefix: opts.prefix
)
execute "ALTER INDEX IF EXISTS #{opts.quoted}.oban_jobs_unique_index_old RENAME TO oban_jobs_unique_index"
execute "ALTER INDEX IF EXISTS #{opts.quoted}.oban_jobs_partition_index_old RENAME TO oban_jobs_partition_index"
end
end
end

View file

@ -15,6 +15,16 @@ defmodule Oban.Pro.Partition do
@conf_tab Module.concat(__MODULE__, Conf)
@keys_tab Module.concat(__MODULE__, Keys)
defmacrop coalesce_partition(meta) do
quote do
fragment(
"COALESCE(?->'global_limit'->'partition', ?->'rate_limit'->'partition')",
unquote(meta),
unquote(meta)
)
end
end
@impl Oban.Pro.Handler
def on_start do
:ets.new(@conf_tab, [:set, :named_table, :public, read_concurrency: true])
@ -131,6 +141,58 @@ defmodule Oban.Pro.Partition do
end
end
# Rescuing
@doc false
def repair_partitions(conf, opts) do
limit = Keyword.fetch!(opts, :limit)
timeout = Keyword.fetch!(opts, :timeout)
for {queue, partition} <- partitioned_queues(conf, timeout) do
query =
Job
|> where([j], j.queue == ^queue)
|> where([j], j.state in ~w(available scheduled retryable))
|> where([j], fragment("NOT ? \\? 'partition_key'", j.meta))
|> limit(^limit)
conf
|> Repo.all(query, timeout: timeout)
|> Enum.group_by(fn job -> gen_partition_key(job, partition) end)
|> Enum.each(fn {key, grouped_jobs} ->
ids = Enum.map(grouped_jobs, & &1.id)
Job
|> where([j], j.id in ^ids)
|> update([j], set: [meta: fragment("jsonb_set(?, '{partition_key}', ?)", j.meta, ^key)])
|> then(&Repo.update_all(conf, &1, [], timeout: timeout))
end)
end
:ok
end
defp gen_partition_key(job, partition) do
job
|> Ecto.Changeset.change()
|> gen_key(partition)
end
defp partitioned_queues(conf, timeout) do
normalize_partition = fn partition ->
fields = partition |> Map.get("fields", []) |> Enum.map(&String.to_existing_atom/1)
%{fields: fields, keys: Map.get(partition, "keys", [])}
end
Producer
|> where([p], fragment("jsonb_typeof(?) = 'object'", coalesce_partition(p.meta)))
|> select([p], {p.queue, coalesce_partition(p.meta)})
|> distinct(true)
|> then(&Repo.all(conf, &1, timeout: timeout))
|> Enum.map(fn {queue, partition} -> {queue, normalize_partition.(partition)} end)
end
@doc """
We can't guarantee that the queue is running locally, running on a current node, or running at
all. This will check the following locations:

View file

@ -150,6 +150,16 @@ defmodule Oban.Pro.Plugins.DynamicCron do
expression inserts a job once each time the plugin starts and won't re-fire until the next
restart.
## Job Metadata
Scheduled jobs receive automatic metadata identifying the entry and the slot it fired for:
* `cron` - always `true`
* `cron_at` - the slot time in UTC, stable across retries and guaranteed catch-ups
* `cron_expr` - the cron expression
* `cron_name` - the entry's `:name`
* `cron_tz` - the timezone the expression evaluates against
## Automatic Synchronization
Synchronizing persisted entries manually requires two deploys: one to flag it with `delete:
@ -244,6 +254,11 @@ defmodule Oban.Pro.Plugins.DynamicCron do
typical CRUD operations. The `DynamicCron` plugin provides convenience functions to simplify
those operations.
> #### Treat Expressions as Trusted Input {: .warning}
>
> Cron expressions passed to `insert/2` and `update/3` are treated as trusted input.
> Validate or constrain any user-provided expressions before passing them along.
The `insert/1` function takes a list of one or more tuples with the same `{expression, worker}`
or `{expression, worker, options}` format as the plugin's `crontab` option:
@ -697,7 +712,20 @@ defmodule Oban.Pro.Plugins.DynamicCron do
{args, opts} = Keyword.pop(entry.opts, :args, %{})
{tz, opts} = Keyword.pop(opts, :timezone, state.timezone)
meta = %{cron: true, cron_expr: entry.expression, cron_name: entry.name, cron_tz: tz}
# Add a minute so last_at's internal -1 minute and second truncation don't
# snap an on-time slot back to the previous match.
cron_at =
entry.parsed
|> Expression.last_at(DateTime.add(DateTime.now!(tz), 1, :minute))
|> DateTime.shift_zone!("Etc/UTC")
meta = %{
cron: true,
cron_at: cron_at,
cron_expr: entry.expression,
cron_name: entry.name,
cron_tz: tz
}
opts =
entry.worker.__opts__()

View file

@ -28,8 +28,9 @@ defmodule Oban.Pro.Plugins.DynamicLifeline do
## Options
* `:repair_limit` the maximum number of jobs each repair operation (workflows, chains,
partitions, chunks) will process per cycle. Defaults to `1000`.
* `:repair_limit` the maximum number of jobs each partition or chunk repair will process per
cycle. Defaults to `1000`. Workflow and chain repairs aren't subject to this limit—they're
naturally bounded by the set of stuck compositions.
* `:retry_exhausted` when `true`, jobs that have exhausted their attempts are rescued back to
`available` with an incremented `max_attempts` instead of being discarded. Defaults to
@ -43,20 +44,20 @@ defmodule Oban.Pro.Plugins.DynamicLifeline do
The plugin automatically performs several repair operations during each rescue cycle to keep
workflows, chains, partitions, and chunks healthy.
* **Workflow repair** Jobs held waiting for dependencies that were deleted or missed a
scheduling event are released. This handles edge cases where workflow jobs get stuck due to
incomplete dependency resolution.
* **Chain repair** Chain jobs waiting on deleted or stuck predecessors are released to
continue processing.
* **Chain repair** Similar to workflows, chain jobs waiting on deleted or stuck predecessors
are released to continue processing.
* **Chunk repair** Chunk jobs that are missing a `chunk_id` in their metadata have their
chunk ID computed from the job's `chunk_by` configuration and set automatically. This can
happen when chunk settings are changed after jobs are already enqueued.
* **Partition repair** Jobs in partitioned queues that are missing a `partition_key` in their
metadata (e.g., jobs scheduled before a queue was partitioned) have their partition key
computed and set automatically.
* **Chunk repair** Chunk jobs that are missing a `chunk_id` in their metadata have their
chunk ID computed from the job's `chunk_by` configuration and set automatically. This can
happen when chunk settings are changed after jobs are already enqueued.
* **Workflow repair** Jobs held waiting for dependencies that were deleted or missed a
scheduling event are released. This handles edge cases where workflow jobs get stuck due to
incomplete dependency resolution.
Each repair operation processes up to `repair_limit` jobs per cycle.
@ -111,7 +112,7 @@ defmodule Oban.Pro.Plugins.DynamicLifeline do
alias Oban.{Job, Peer, Repo, Validation}
alias Oban.Pro.Chunk
alias Oban.Pro.Engines.Smart
alias Oban.Pro.{Partition, Producer, Utils, Workflow}
alias Oban.Pro.{Partition, Producer, Workflow}
alias Oban.Pro.Stages.{Chain, Hooks}
require Logger
@ -144,16 +145,6 @@ defmodule Oban.Pro.Plugins.DynamicLifeline do
end
end
defmacrop coalesce_partition(meta) do
quote do
fragment(
"COALESCE(?->'global_limit'->'partition', ?->'rate_limit'->'partition')",
unquote(meta),
unquote(meta)
)
end
end
defmacrop track_rescued(meta) do
quote do
fragment(
@ -233,10 +224,10 @@ defmodule Oban.Pro.Plugins.DynamicLifeline do
:telemetry.span([:oban, :plugin], meta, fn ->
orphan_meta = rescue_orphaned(state)
:ok = repair_workflows(state)
:ok = repair_chains(state)
:ok = repair_chunks(state)
:ok = repair_partitions(state)
:ok = repair_chunk_ids(state)
:ok = repair_workflows(state)
{:ok, Map.merge(meta, orphan_meta)}
end)
@ -316,7 +307,7 @@ defmodule Oban.Pro.Plugins.DynamicLifeline do
Repo.update_all(state.conf, query, [], timeout: state.timeout)
catch
:error, %Postgrex.Error{postgres: %{code: :unique_violation, detail: detail}} ->
Smart.clear_uniq_violation(state.conf, detail)
Smart.clear_uniq_violation(state.conf, detail, ~w(available executing retryable))
{0, []}
end
@ -333,7 +324,7 @@ defmodule Oban.Pro.Plugins.DynamicLifeline do
Repo.update_all(state.conf, query, [], timeout: state.timeout)
catch
:error, %Postgrex.Error{postgres: %{code: :unique_violation, detail: detail}} ->
Smart.clear_uniq_violation(state.conf, detail)
Smart.clear_uniq_violation(state.conf, detail, ~w(available executing retryable))
{0, []}
end
@ -360,100 +351,21 @@ defmodule Oban.Pro.Plugins.DynamicLifeline do
{count, partial_jobs}
end
# Composition Repair
defp repair_workflows(state) do
Workflow.rescue_workflows(state.conf, timeout: state.timeout, limit: state.repair_limit)
end
# Repair
defp repair_chains(state) do
Chain.rescue_chains(state.conf, timeout: state.timeout, limit: state.repair_limit)
Chain.repair_chains(state.conf, timeout: state.timeout)
end
# Partition Repair
defp repair_chunks(state) do
Chunk.repair_chunks(state.conf, limit: state.repair_limit, timeout: state.timeout)
end
defp repair_partitions(state) do
for {queue, partition} <- partitioned_queues(state) do
query =
Job
|> where([j], j.queue == ^queue)
|> where([j], j.state in ~w(available scheduled retryable))
|> where([j], fragment("NOT ? \\? 'partition_key'", j.meta))
|> limit(^state.repair_limit)
state.conf
|> Repo.all(query, timeout: state.timeout)
|> Enum.group_by(fn job -> gen_partition_key(job, partition) end)
|> Enum.each(fn {key, grouped_jobs} ->
ids = Enum.map(grouped_jobs, & &1.id)
Job
|> where([j], j.id in ^ids)
|> update([j], set: [meta: fragment("jsonb_set(?, '{partition_key}', ?)", j.meta, ^key)])
|> then(&Repo.update_all(state.conf, &1, [], timeout: state.timeout))
end)
end
:ok
Partition.repair_partitions(state.conf, limit: state.repair_limit, timeout: state.timeout)
end
defp gen_partition_key(job, partition) do
job
|> Ecto.Changeset.change()
|> Partition.gen_key(partition)
end
defp partitioned_queues(state) do
normalize_partition = fn partition ->
fields = partition |> Map.get("fields", []) |> Enum.map(&String.to_existing_atom/1)
%{fields: fields, keys: Map.get(partition, "keys", [])}
end
Producer
|> where([p], fragment("jsonb_typeof(?) = 'object'", coalesce_partition(p.meta)))
|> select([p], {p.queue, coalesce_partition(p.meta)})
|> distinct(true)
|> then(&Repo.all(state.conf, &1, timeout: state.timeout))
|> Enum.map(fn {queue, partition} -> {queue, normalize_partition.(partition)} end)
end
# Chunk Repair
defp repair_chunk_ids(state) do
query =
Job
|> where([j], j.state in ~w(available scheduled retryable))
|> where([j], fragment("? \\? 'chunk_by'", j.meta))
|> where([j], fragment("NOT ? \\? 'chunk_id'", j.meta))
|> limit(^state.repair_limit)
state.conf
|> Repo.all(query, timeout: state.timeout)
|> Enum.group_by(&gen_chunk_id/1)
|> Enum.each(fn {chunk_id, grouped_jobs} ->
ids = Enum.map(grouped_jobs, & &1.id)
Job
|> where([j], j.id in ^ids)
|> update([j], set: [meta: fragment("jsonb_set(?, '{chunk_id}', ?)", j.meta, ^chunk_id)])
|> then(&Repo.update_all(state.conf, &1, [], timeout: state.timeout))
end)
:ok
end
defp gen_chunk_id(job) do
chunk_by = normalize_chunk_by(job.meta["chunk_by"])
Chunk.to_chunk_id(chunk_by, job.worker, job.args, queue: job.queue, meta: job.meta)
end
defp normalize_chunk_by(chunk_by) do
Enum.map(chunk_by, fn
"worker" -> :worker
["args", keys] -> [:args, Enum.map(keys, &Utils.maybe_to_atom/1)]
["meta", keys] -> [:meta, Enum.map(keys, &Utils.maybe_to_atom/1)]
end)
defp repair_workflows(state) do
Workflow.rescue_workflows(state.conf, timeout: state.timeout)
end
end

View file

@ -82,7 +82,7 @@ defmodule Oban.Pro.Stages.Chain do
end
@doc false
def rescue_chains(conf, opts \\ []) do
def repair_chains(conf, opts \\ []) do
prev_query =
Job
|> where([j], j.state in ~w(available suspended scheduled retryable executing))
@ -98,10 +98,8 @@ defmodule Oban.Pro.Stages.Chain do
|> limit(1)
subquery =
if Utils.has_legacy_suspended?(conf) do
subquery
|> where([j], j.state == "scheduled")
|> where([j], fragment("?->>'on_hold' = 'true'", j.meta))
if Utils.has_legacy_chains?(conf) do
legacy_suspended?(subquery)
else
where(subquery, [j], j.state == "suspended")
end
@ -183,16 +181,15 @@ defmodule Oban.Pro.Stages.Chain do
subquery =
Job
|> select([j], j.id)
|> where([j], fragment("? \\? 'chain_id'", j.meta))
|> where([j], fragment("?->>'chain_id'", j.meta) == ^chain_id)
|> order_by(asc: :id)
|> limit(1)
|> lock("FOR UPDATE")
subquery =
if Utils.has_legacy_suspended?(conf) do
subquery
|> where([j], j.state == "scheduled")
|> where([j], fragment("?->>'on_hold' = 'true'", j.meta))
if Utils.has_legacy_chains?(conf) do
legacy_suspended?(subquery)
else
where(subquery, [j], j.state == "suspended")
end
@ -216,6 +213,15 @@ defmodule Oban.Pro.Stages.Chain do
# Helpers
defp legacy_suspended?(subquery) do
where(
subquery,
[j],
j.state == "suspended" or
(j.state == "scheduled" and fragment("?->>'on_hold' = 'true'", j.meta))
)
end
defp merge_conf_opts(conf, opts) do
case Keyword.fetch(opts, :chain) do
{:ok, chain} ->

View file

@ -71,6 +71,10 @@ defmodule Oban.Pro.Stages.Hooks do
end
end
def clear_hooks do
:persistent_term.put(:oban_pro_hooks, [])
end
# The `Workflow.after_cancelled` hook is deprecated and will be removed in a future version.
def execute_after_cancelled(reason, job) do
with {worker, job, opts} <- resolve_processing(job) do
@ -93,6 +97,8 @@ defmodule Oban.Pro.Stages.Hooks do
def handle_event([:oban, :job, _], _timing, meta, _conf) do
with {worker, job, opts} <- resolve_processing(meta.job) do
job = %{job | unsaved_error: meta.job.unsaved_error}
execute_hook(worker, opts, :after_process, [to_hook_state(meta.state), job, meta[:result]])
end
end

View file

@ -1,8 +1,12 @@
defmodule Oban.Pro.Utils do
@moduledoc false
import Ecto.Query
alias Ecto.Changeset
@legacy_ttl :timer.minutes(5)
@spec encode64(term(), [:compressed | :deterministic]) :: String.t()
def encode64(term, opts \\ [:compressed]) do
term
@ -24,38 +28,50 @@ defmodule Oban.Pro.Utils do
|> Base.encode64(padding: false)
end
@spec persistent_cache(tuple(), fun()) :: any()
@spec persistent_cache(tuple(), (-> any())) :: any()
def persistent_cache(key, fun) when is_function(fun, 0) do
persistent_cache(key, :infinity, fun)
end
@spec persistent_cache(tuple(), timeout(), (-> any())) :: any()
def persistent_cache(key, ttl, fun) when is_function(fun, 0) do
now = System.monotonic_time(:millisecond)
exp = if ttl == :infinity, do: :infinity, else: now + ttl
case :persistent_term.get(key, nil) do
nil -> tap(fun.(), &:persistent_term.put(key, &1))
val -> val
{val, :infinity} -> val
{val, expires_at} when expires_at > now -> val
_ -> tap(fun.(), &:persistent_term.put(key, {&1, exp}))
end
end
@doc """
Check if any legacy suspended jobs exist (scheduled + on_hold pattern).
Result is cached in persistent_term per prefix and only checked once per application lifetime.
"""
@spec has_legacy_suspended?(Oban.Config.t()) :: boolean()
def has_legacy_suspended?(conf) do
import Ecto.Query
persistent_cache({__MODULE__, :legacy_suspended, conf.prefix}, fn ->
query =
from(job in Oban.Job,
where: fragment("? \\? 'workflow_id'", job.meta),
where: fragment("? ->> 'workflow_id' IS NOT NULL", job.meta),
where: job.state == "scheduled",
where: fragment("? ->> 'on_hold' = 'true'", job.meta),
select: true,
limit: 1
)
Oban.Repo.exists?(conf, query)
@spec has_legacy_workflows?(Oban.Config.t()) :: boolean()
def has_legacy_workflows?(conf) do
persistent_cache({__MODULE__, :legacy_workflows, conf.name}, @legacy_ttl, fn ->
has_legacy?(conf, "workflow_id")
end)
end
@spec has_legacy_chains?(Oban.Config.t()) :: boolean()
def has_legacy_chains?(conf) do
persistent_cache({__MODULE__, :legacy_chains, conf.name}, @legacy_ttl, fn ->
has_legacy?(conf, "chain_id")
end)
end
defp has_legacy?(conf, key) do
query =
from(job in Oban.Job,
where: fragment("? \\? ?", job.meta, ^key),
where: job.state == "scheduled",
where: fragment("? ->> 'on_hold' = 'true'", job.meta),
select: true,
limit: 1
)
Oban.Repo.exists?(conf, query)
end
@spec validate_opts(list(), fun()) :: :ok | {:error, term()}
def validate_opts(opts, validator) do
Enum.reduce_while(opts, :ok, fn opt, acc ->

View file

@ -18,6 +18,8 @@ defmodule Oban.Pro.Worker do
worker name to continue without exceptions using the new worker code.
* [🪝 Worker Hooks](#module-worker-hooks) — callbacks triggered around job execution, defined as
callback functions on the worker, or in a separate module for reuse.
* [📡 Awaiting Signals](#module-awaiting-signals) — pause mid-execution and resume when a signal
delivers an external decision from anywhere in your app.
* [ Rate Limiting Weight](#module-rate-limiting-weight) — assign custom weights to jobs to
control the rate limit quota consumed by a job.
@ -702,6 +704,51 @@ defmodule Oban.Pro.Worker do
These callbacks can also be defined in shared hook modules and attached explicitly or globally,
just like execution hooks.
## Awaiting Signals
Jobs can pause mid-execution to wait for an external decision and resume when a signal arrives.
This turns long-running workers into durable state machines that wait for human approval,
third-party callbacks, or any other out-of-band event without holding a worker process or
database connection open.
Pause a job with `await_signal/1` from inside `process/1` and resume it with `signal/2,3` from
anywhere. For example, wait for approval before charging a credit card:
```elixir
defmodule MyApp.Approval do
use Oban.Pro.Worker
@impl Oban.Pro.Worker
def process(_job) do
case Oban.Pro.Worker.await_signal(wait_for: {24, :hours}) do
{:ok, %{decision: "approved"}} -> charge_card()
{:ok, %{decision: "rejected"}} -> {:cancel, :rejected}
{:error, :timeout} -> {:cancel, :no_decision}
end
end
end
```
Then signal the job by id when the decision arrives:
```elixir
Oban.Pro.Worker.signal(job_id, %{decision: "approved"})
```
A list of job ids can be used for multiple jobs at once:
```elixir
Oban.Pro.Worker.signal([job_a_id, job_b_id], %{decision: "approved"})
```
A short live wait (`:wait_timeout`, default 5s) blocks during execution so signals that arrive
immediately resume in place. Beyond that window, the job snoozes until either a signal arrives
or the overall `:wait_for` deadline elapses, freeing the worker process during the wait. Signals
are persisted, so one delivered before `await_signal/1` is reached is consumed on the next call.
Payloads may be any native term, as they're encoded before storage and decoded on retrieval, so
atoms, tuples, and structs round-trip intact.
## Rate Limiting Weight
When using rate limiting with the Smart engine, each job consumes one unit of rate limit
@ -761,7 +808,7 @@ defmodule Oban.Pro.Worker do
import Ecto.Query, only: [where: 3, update: 3]
alias Oban.{Job, Notifier, Period, Repo, Validation, Worker}
alias Oban.Pro.Batch
alias Oban.Pro.{Batch, Utils}
alias Oban.Pro.Stages.{Chain, Deadline, Encrypted, Executing, Hooks, Rate, Recorded, Structured}
@typedoc """
@ -1385,9 +1432,9 @@ defmodule Oban.Pro.Worker do
@doc """
Deliver a signal to one or more jobs by id.
The `payload` is merged into the target jobs' `meta` under the `"signal"` key. If a job is
parked inside `await_signal/2`, its `scheduled_at` is bumped to `now()` so it resumes
immediately.
The `payload` may be any term, since it's encoded before storage and decoded by
`await_signal/1`, so atoms, tuples, and structs stay intact. If a job was parked by an
`await_signal/1` timeout, it is made available to execute immediately.
Signals are fire-and-forget. Delivery to a job in a terminal state, or a job that doesn't
exist, is a no-op and still returns `:ok`. When multiple signals arrive for the same target
@ -1426,6 +1473,7 @@ defmodule Oban.Pro.Worker do
conf = Oban.config(oban_name)
now = DateTime.utc_now()
ids = List.wrap(id_or_ids)
encoded = Utils.encode64(payload)
query =
Job
@ -1433,7 +1481,7 @@ defmodule Oban.Pro.Worker do
|> where([j], j.state in ~w(suspended available scheduled executing retryable))
|> update([j],
set: [
meta: fragment("jsonb_set(?, '{signal}', ?::jsonb)", j.meta, ^payload),
meta: fragment("jsonb_set(?, '{signal}', to_jsonb(?::text))", j.meta, ^encoded),
scheduled_at: maybe_schedule(j, ^now)
]
)
@ -1441,7 +1489,7 @@ defmodule Oban.Pro.Worker do
Repo.transaction(conf, fn ->
Repo.update_all(conf, query, [])
Notifier.notify(conf, :pro_signal, %{job_ids: ids, signal: payload})
Notifier.notify(conf, :pro_signal, %{job_ids: ids, signal: encoded})
end)
:ok
@ -1488,7 +1536,7 @@ defmodule Oban.Pro.Worker do
end)
cond do
payload = meta["signal"] -> {:ok, payload}
encoded = meta["signal"] -> {:ok, Utils.decode64(encoded)}
past_deadline?(wait_until) -> {:error, :timeout}
true -> await_signal(conf.name, id, wait_until, wait_timeout)
end
@ -1514,7 +1562,7 @@ defmodule Oban.Pro.Worker do
receive do
{:notification, :pro_signal, %{"job_ids" => ids, "signal" => signal}} ->
if id in ids do
{:ok, signal}
{:ok, Utils.decode64(signal)}
else
await_loop(id, wait_until, wait_timeout)
end

View file

@ -469,8 +469,8 @@ defmodule Oban.Pro.Workflow do
@impl Oban.Pro.Worker
def process(_job) do
case Oban.Pro.Worker.await_signal(wait_for: {24, :hours}) do
{:ok, %{"decision" => "approved"}} -> charge_card()
{:ok, %{"decision" => "rejected"}} -> {:cancel, :rejected}
{:ok, %{decision: "approved"}} -> charge_card()
{:ok, %{decision: "rejected"}} -> {:cancel, :rejected}
{:error, :timeout} -> {:cancel, :no_decision}
end
end
@ -1073,7 +1073,7 @@ defmodule Oban.Pro.Workflow do
{__MODULE__, :on_flush, [[sup_workflow_id, workflow_id], conf]}
%{"workflow_id" => workflow_id} ->
{__MODULE__, :on_flush, [workflow_id, conf]}
{__MODULE__, :on_flush, [[workflow_id], conf]}
_ ->
:ignore
@ -1178,7 +1178,7 @@ defmodule Oban.Pro.Workflow do
held_query = lock(Job, "FOR UPDATE SKIP LOCKED")
held_query =
if Utils.has_legacy_suspended?(conf) do
if Utils.has_legacy_workflows?(conf) do
where(
held_query,
[j],
@ -1950,32 +1950,12 @@ defmodule Oban.Pro.Workflow do
_ -> graft_id
end
sub_names = expand_sub_names(workflow)
changesets =
Enum.map(workflow.changesets, fn changeset ->
changeset = Changeset.update_change(changeset, :meta, &Map.put(&1, :workflow_id, root_id))
# Convert internal wildcard deps to explicit name deps so internal dependencies resolve
# correctly.
deps = get_in(changeset.changes, [:meta, :deps]) || []
updated_deps =
Enum.flat_map(deps, fn
[sub_id, "*"] ->
case Map.get(sub_names, sub_id) do
nil -> [[sub_id, "*"]]
names -> names
end
other ->
[other]
end)
if updated_deps != deps do
Changeset.update_change(changeset, :meta, &Map.put(&1, :deps, updated_deps))
else
if get_in(changeset.changes, [:meta, :sup_workflow_id]) do
changeset
else
Changeset.update_change(changeset, :meta, &Map.put(&1, :workflow_id, root_id))
end
end)
@ -1986,21 +1966,6 @@ defmodule Oban.Pro.Workflow do
|> add_workflow(graft_name, workflow, opts)
end
defp expand_sub_names(workflow) do
Enum.reduce(workflow.changesets, %{}, fn changeset, acc ->
meta = changeset.changes[:meta] || %{}
name = meta[:name]
sub_id = if meta[:sup_workflow_id], do: meta[:workflow_id]
if sub_id && name do
Map.update(acc, sub_id, [name], &[name | &1])
else
acc
end
end)
end
defp extract_graft! do
case Process.get(:oban_processing) do
{_module, %{meta: %{"graft_id" => _}} = job, _opts} ->
@ -2846,7 +2811,7 @@ defmodule Oban.Pro.Workflow do
workflow_ids = Repo.all(conf, query, opts)
workflow_ids =
if Utils.has_legacy_suspended?(conf) do
if Utils.has_legacy_workflows?(conf) do
legacy_query =
Job
|> select([j], fragment("?->>'workflow_id'", j.meta))
@ -3053,9 +3018,9 @@ defmodule Oban.Pro.Workflow do
now = DateTime.utc_now()
base =
if Utils.has_legacy_suspended?(conf) do
if Utils.has_legacy_workflows?(conf) do
Job
|> where([j], j.id in ^ids and j.state == "scheduled")
|> where([j], j.id in ^ids and j.state in ["scheduled", "suspended"])
|> update([j], set: [meta: drop_hold(j.meta)])
else
where(Job, [j], j.id in ^ids and j.state == "suspended")

View file

@ -1,7 +1,7 @@
defmodule Oban.Pro.MixProject do
use Mix.Project
@version "1.7.0"
@version "1.7.5"
def project do
[
@ -18,7 +18,7 @@ defmodule Oban.Pro.MixProject do
package: package(),
name: "Oban Pro",
description: "Oban Pro Component",
xref: [exclude: [Oban.JSON, Oban.Validation]],
elixirc_options: [no_warn_undefined: [Oban.JSON, Oban.Validation]],
# Dialyzer
dialyzer: [
@ -65,6 +65,7 @@ defmodule Oban.Pro.MixProject do
{:oban, "~> 2.21"},
{:ecto_sql, "~> 3.12"},
{:postgrex, "~> 0.22", optional: true},
{:decimal, "~> 3.0", optional: true},
# Test and Dev
{:stream_data, "~> 1.1", only: [:test, :dev]},
@ -126,7 +127,11 @@ defmodule Oban.Pro.MixProject do
logo: "assets/oban-pro-logo.svg",
assets: %{"assets" => "assets"},
nest_modules_by_prefix: nest_modules_by_prefix(),
skip_undefined_reference_warnings_on: ["CHANGELOG.md", "guides/upgrading/v1.5.md"],
skip_undefined_reference_warnings_on: [
"CHANGELOG.md",
"guides/upgrading/v1.5.md",
"guides/upgrading/v1.7.md"
],
before_closing_body_tag: fn _ ->
"""
<script>document.querySelector('footer.footer p').remove()</script>

View file

@ -43,6 +43,22 @@ args_schema do
end
```
## Uniqueness
Configure uniqueness with state groups, not explicit state lists:
```elixir
# Good — uses the :incomplete group
use Oban.Pro.Worker, unique: [period: 60, states: :incomplete]
# Bad — explicit state lists drift out of sync with new states
use Oban.Pro.Worker, unique: [period: 60, states: [:available, :scheduled, :executing]]
```
Available groups: `:incomplete` (everything except `:cancelled`, `:completed`, `:discarded`) and
`:scheduled` (just the `scheduled` state, safe for debouncing). Explicit lists silently miss states
added in later releases and cause subtle duplication bugs.
## Encrypted jobs
Protect sensitive args with transparent encryption:

BIN
vendor/oban_web/.hex vendored

Binary file not shown.

View file

@ -15,7 +15,8 @@ workflow progress, activity counts, duration, and nested sub-workflows. Workflow
by properties like name, workers, or status.
<video autoplay loop muted playsinline loading="lazy" preload="none" style="width: 100%; border-radius: 12px;">
<source src="https://media.oban.pro/web-2-12-workflows-av1.mp4" type="video/mp4">
<source src="https://media.oban.pro/web-2-12-workflows-av1.mp4" type="video/mp4" codecs="av01">
<source src="https://media.oban.pro/web-2-12-workflows.mp4" type="video/mp4">
</video>
Clicking into a workflow brings you to a detail view with an interactive graph showing jobs as
@ -34,7 +35,8 @@ There's also a new page for viewing and managing cron entries. The table display
dynamic entries with history sparklines and activity details.
<video autoplay loop muted playsinline loading="lazy" preload="none" style="width: 100%; border-radius: 12px;">
<source src="https://media.oban.pro/web-2-12-crons-av1.mp4" type="video/mp4">
<source src="https://media.oban.pro/web-2-12-crons-av1.mp4" type="video/mp4" codecs="av01">
<source src="https://media.oban.pro/web-2-12-crons.mp4" type="video/mp4">
</video>
The cron detail view includes natural language expressions like "Daily at 8:00 and 9:00" or
@ -49,7 +51,8 @@ The job detail page is rebuilt with a full-width layout and a new timeline compo
the job state machine as a branching diagram rather than a linear progression.
<video autoplay loop muted playsinline loading="lazy" preload="none" style="width: 100%; border-radius: 12px;">
<source src="https://media.oban.pro/web-2-12-jobs-details-av1.mp4" type="video/mp4">
<source src="https://media.oban.pro/web-2-12-jobs-details-av1.mp4" type="video/mp4" codecs="av01">
<source src="https://media.oban.pro/web-2-12-jobs-details.mp4" type="video/mp4">
</video>
A scoped chart displays execution history for that worker's previous jobs and jobs in an
@ -66,13 +69,112 @@ throughput for each queue. The queue sidebar provided minimal value, and it was
room for the additional data displayed per-row.
<video autoplay loop muted playsinline loading="lazy" preload="none" style="width: 100%; border-radius: 12px;">
<source src="https://media.oban.pro/web-2-12-queues-av1.mp4" type="video/mp4">
<source src="https://media.oban.pro/web-2-12-queues-av1.mp4" type="video/mp4" codecs="av01">
<source src="https://media.oban.pro/web-2-12-queues.mp4" type="video/mp4">
</video>
The queue detail page adds status badges for paused, partial, and terminating states, with
pause/resume, stop, and edit buttons in the header. Partitioning controls are expanded with meta
options and burst mode configuration.
## v2.12.5 - 2026-05-26
### Enhancements
- [Jobs] Display awaitable signals in the job details page
Add a section that decodes and displays signal payloads sent via
`Oban.Pro.Worker.signal/2`. While a job is parked waiting, the section
shows "Awaiting Signal" with the deadline. Once a signal arrives, it
switches to "Received Signal".
- [Resolver] Add `format_signal/2` resolver callback
This allows customizing the decoded output,mirroring what's available
with `format_recorded/2`.
### Bug Fixes
- [Jobs] Restrict unauthorized job editing and updates with new permission
The save-job event handler previously dispatched changes from any client
without checking access controls, allowing a read-only user to rewrite a
job's worker module and potentially trigger code execution on the next
attempt. Editing now requires `:update_jobs` permission, which is
enabled by default for `:all` and disabled for `:read_only`.
- [Cron] Prevent malicious cron expressions from unrestricted memory allocation
A maliciously crafted cron expression like "0 0 1--100000000 \* \*" could
trigger multi-gigabyte allocations when `describe/1` eagerly expanded
the range during formatting. Range, value, and step parsing now validate
against per-field bounds and require ranges to be non-decreasing, so
out-of-domain inputs are rejected before any expansion occurs.
## v2.12.4 - 2026-05-11
### Changes
- [Dashboard] Upgrade oban_pro dependency to full v1.7 release
Require the full v1.7 release rather than a release candidate.
### Bug Fixes
- [Dashboard] Escape names with reserved URL characters in paths
Safely handle queues or crons with names like `foo/bar.baz` when linking from the queues and
crons tables.
- [Workflow] Fix workflow queries ignoring custom prefix
Two raw SQL fragments in WorkflowQuery referenced tables without a schema qualifier, causing a
mismatch with the configured Oban prefix. Both fragments now inject the prefix as a quoted
identifier so they honor the configured prefix like any other Oban.Repo queries.
- [Cron] Fix crash on cron page when an entry uses @reboot
The `next_at/2` function returns `:unknown` for `@reboot` crons, which fell through to
`maybe_to_unix/1` and crashed. Guard the helper on a `DateTime` struct and return an empty
string for anything else, safely convering `nil` or `:unknown`.
- [Cron] Fix crash loading cron history on SQLite
The `COALESCE` fragment used to compute the `finished_at` time was untyped, so Ecto couldn't
apply the `:utc_datetime_usec` load callback. Postgrex would cast the value automatically, but
exqlite returned a string and crashed downstream locations expecting a DateTime.
## v2.12.3 - 2026-04-15
### Bug Fixes
- [Dashboard] Switch icons from inline SVG to CSS masks
CSP doesn't allow inline styles, which includes the url-masks that our app was using for icons.
This removes the icons/asset pipeline in favor of the standard Tailwind plugin approach used by
modern Phoenix apps
## v2.12.2 - 2026-03-31
### Enhancements
- [Jobs] Allow editing jobs in any state except `executing`
`Oban.update_job/3` allows editing any non-executing job, so the detail component's edit form
should as well.
- [Standalone] Ship inetrc in standalone Docker image for native DNS resolution (#173)
The BEAM VM's built-in DNS resolver ignores `/etc/resolv.conf`, which prevents the standalone
image from resolving internal hostnames on platforms like Fly.io and Kubernetes with CoreDNS.
### Bug Fixes
- [Query] Prevent table check with non-PostgreSQL engines
The crons and workflows table checks only apply to Pro, and only Postgres engines should check
whether the tables exist.
## v2.12.1 - 2026-03-25
### Bug Fixes
@ -164,7 +266,6 @@ features. For enhancements, a video is worth many thousands of words.
- [Crons] Add cron parser for complex expressions
Parse cron fields into structured data before describing them, enabling support for:
- Combined DOM/DOW patterns like "The 1st, only on Mondays"
- Complement detection, "Daily except the 1st" or "except Tuesdays"
- Multiple hour values, "Daily at 8:00, 9:00, and 10:00"
@ -172,7 +273,7 @@ features. For enhancements, a video is worth many thousands of words.
It's also switched to a 24-hour time format for international consistency.
- [Queues] Remove sidebar from queues page The sidebar filters (paused, terminating, modes, nodes) added little
- [Queues] Remove sidebar from queues page The sidebar filters (paused, terminating, modes, nodes) added little
The sidebar filters (paused, terminating, modes, nodes) added little value for a typically small
dataset while consuming significant screen space. Filtering remains available via the search
@ -234,4 +335,3 @@ features. For enhancements, a video is worth many thousands of words.
The PG notifier can't (easily) connect to an external cluster for notifications. Connection is
possible through the Postgres notifier.

View file

@ -7,20 +7,6 @@ defmodule Oban.Web.Assets do
@static_path Application.app_dir(:oban_web, ["priv", "static"])
# Icons
icon_files =
for dir <- ["outline", "solid", "special"],
base = Path.join([@static_path, "icons", dir]),
file <- File.ls!(base),
String.ends_with?(file, ".svg") do
path = Path.join(base, file)
Module.put_attribute(__MODULE__, :external_resource, path)
{Path.join(dir, file), File.read!(path)}
end
@icons Map.new(icon_files)
# Font
@external_resource font_path = Path.join(@static_path, "fonts/Inter.woff2")
@ -65,21 +51,6 @@ defmodule Oban.Web.Assets do
serve_asset(conn, @font, "font/woff2")
end
def call(conn, :icon) do
icon_path = Enum.join(conn.path_params["path"], "/")
case Map.get(@icons, icon_path) do
nil ->
conn
|> put_resp_header("content-type", "text/plain")
|> send_resp(404, "Icon not found")
|> halt()
contents ->
serve_asset(conn, contents, "image/svg+xml")
end
end
defp serve_asset(conn, contents, content_type) do
conn
|> put_resp_header("content-type", content_type)

View file

@ -106,7 +106,7 @@ defmodule Oban.Web.Components.Core do
phx-value-choice={@value}
>
<%= if to_string(@value) == to_string(@selected) do %>
<Icons.check class="w-5 h-5" />
<Icons.icon name="icon-check" class="w-5 h-5" />
<% else %>
<span class="block w-5 h-5"></span>
<% end %>
@ -142,7 +142,7 @@ defmodule Oban.Web.Components.Core do
rel="check"
>
<div class={["w-4 h-4 flex items-center justify-center rounded border", @style]}>
<Icons.check class="w-3 h-3 text-white dark:text-gray-900" />
<Icons.icon name="icon-check" class="w-3 h-3 text-white dark:text-gray-900" />
</div>
</button>
"""
@ -174,9 +174,9 @@ defmodule Oban.Web.Components.Core do
>
<div class={["w-4 h-4 flex items-center justify-center rounded border", @style]}>
<%= if @checked == :some do %>
<Icons.indeterminate class="w-3 h-3 text-white dark:text-gray-900" />
<Icons.icon name="icon-indeterminate" class="w-3 h-3 text-white dark:text-gray-900" />
<% else %>
<Icons.check class="w-3 h-3 text-white dark:text-gray-900" />
<Icons.icon name="icon-check" class="w-3 h-3 text-white dark:text-gray-900" />
<% end %>
</div>
</button>
@ -203,40 +203,44 @@ defmodule Oban.Web.Components.Core do
"""
end
defp badge_icon(%{name: "camera"} = assigns), do: ~H[<Icons.camera class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "camera"} = assigns),
do: ~H[<Icons.icon name="icon-camera" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "life_buoy"} = assigns),
do: ~H[<Icons.life_buoy class="h-4 w-4 shrink-0" />]
do: ~H[<Icons.icon name="icon-life-buoy" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "lock_closed"} = assigns),
do: ~H[<Icons.lock_closed class="h-4 w-4 shrink-0" />]
do: ~H[<Icons.icon name="icon-lock-closed" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "signal"} = assigns),
do: ~H[<Icons.icon name="icon-signal" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "sparkles"} = assigns),
do: ~H[<Icons.sparkles class="h-4 w-4 shrink-0" />]
do: ~H[<Icons.icon name="icon-sparkles" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "table_cells"} = assigns),
do: ~H[<Icons.table_cells class="h-4 w-4 shrink-0" />]
do: ~H[<Icons.icon name="icon-table-cells" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "square_2x2"} = assigns),
do: ~H[<Icons.square_2x2 class="h-4 w-4 shrink-0" />]
do: ~H[<Icons.icon name="icon-square-2x2" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "rectangle_group"} = assigns),
do: ~H[<Icons.rectangle_group class="h-4 w-4 shrink-0" />]
do: ~H[<Icons.icon name="icon-rectangle-group" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "user_group"} = assigns),
do: ~H[<Icons.user_group class="h-4 w-4 shrink-0" />]
do: ~H[<Icons.icon name="icon-user-group" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "link"} = assigns),
do: ~H[<Icons.link class="h-4 w-4 shrink-0" />]
do: ~H[<Icons.icon name="icon-link" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "power"} = assigns),
do: ~H[<Icons.power class="h-4 w-4 shrink-0" />]
do: ~H[<Icons.icon name="icon-power" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "pause_circle"} = assigns),
do: ~H[<Icons.pause_circle class="h-4 w-4 shrink-0" />]
do: ~H[<Icons.icon name="icon-pause-circle" class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "play_pause_circle"} = assigns),
do: ~H[<Icons.play_pause_circle class="h-4 w-4 shrink-0" />]
do: ~H[<Icons.icon name="icon-play-pause-circle" class="h-4 w-4 shrink-0" />]
@doc """
An icon-only button that expands to show label on hover. Supports disabled state.
@ -309,17 +313,23 @@ defmodule Oban.Web.Components.Core do
"""
end
defp button_icon(%{name: "arrow_path"} = assigns), do: ~H[<Icons.arrow_path class={@class} />]
defp button_icon(%{name: "arrow_path"} = assigns),
do: ~H[<Icons.icon name="icon-arrow-path" class={@class} />]
defp button_icon(%{name: "pause_circle"} = assigns),
do: ~H[<Icons.pause_circle class={@class} />]
do: ~H[<Icons.icon name="icon-pause-circle" class={@class} />]
defp button_icon(%{name: "pencil_square"} = assigns),
do: ~H[<Icons.pencil_square class={@class} />]
do: ~H[<Icons.icon name="icon-pencil-square" class={@class} />]
defp button_icon(%{name: "play_circle"} = assigns), do: ~H[<Icons.play_circle class={@class} />]
defp button_icon(%{name: "trash"} = assigns), do: ~H[<Icons.trash class={@class} />]
defp button_icon(%{name: "x_circle"} = assigns), do: ~H[<Icons.x_circle class={@class} />]
defp button_icon(%{name: "play_circle"} = assigns),
do: ~H[<Icons.icon name="icon-play-circle" class={@class} />]
defp button_icon(%{name: "trash"} = assigns),
do: ~H[<Icons.icon name="icon-trash" class={@class} />]
defp button_icon(%{name: "x_circle"} = assigns),
do: ~H[<Icons.icon name="icon-x-circle" class={@class} />]
# Sparkline

View file

@ -30,7 +30,7 @@ defmodule Oban.Web.FormComponents do
phx-hook="Tippy"
class="translate-y-px"
>
<Icons.info_circle class="w-4 h-4 text-gray-400 dark:text-gray-500" />
<Icons.icon name="icon-info-circle" class="w-4 h-4 text-gray-400 dark:text-gray-500" />
</span>
</label>
<%= if @type == "textarea" do %>
@ -110,7 +110,10 @@ defmodule Oban.Web.FormComponents do
class="sr-only peer"
/>
<span class="block w-4 h-4 rounded border border-gray-300 dark:border-gray-600 peer-checked:border-blue-500 peer-checked:bg-blue-500" />
<Icons.check class="w-3 h-3 text-white absolute top-0.5 left-0.5 hidden peer-checked:block" />
<Icons.icon
name="icon-check"
class="w-3 h-3 text-white absolute top-0.5 left-0.5 hidden peer-checked:block"
/>
</div>
{@label}
<span
@ -120,7 +123,7 @@ defmodule Oban.Web.FormComponents do
phx-hook="Tippy"
class="ml-1 translate-y-px"
>
<Icons.info_circle class="w-4 h-4 text-gray-400 dark:text-gray-500" />
<Icons.icon name="icon-info-circle" class="w-4 h-4 text-gray-400 dark:text-gray-500" />
</span>
</label>
</div>

View file

@ -3,60 +3,17 @@ defmodule Oban.Web.Components.Icons do
use Phoenix.Component
@icons_path Application.app_dir(:oban_web, ["priv", "static", "icons"])
attr :name, :string, required: true
attr :class, :any, default: nil
attr :rest, :global
@outline_path Path.join(@icons_path, "outline")
@solid_path Path.join(@icons_path, "solid")
@outline_icons @outline_path
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".svg"))
|> Enum.map(&String.trim_trailing(&1, ".svg"))
@solid_icons @solid_path
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".svg"))
|> Enum.map(&String.trim_trailing(&1, ".svg"))
for icon <- @outline_icons do
@external_resource Path.join(@outline_path, "#{icon}.svg")
func_name = icon |> String.replace("-", "_") |> String.to_atom()
@doc false
attr :class, :any, default: "w-6 h-6"
attr :rest, :global
def unquote(func_name)(assigns) do
assigns = assign(assigns, :icon_path, "outline/#{unquote(icon)}.svg")
render_icon(assigns)
end
def icon(assigns) do
~H"""
<span class={[@name, @class]} aria-hidden="true" {@rest} />
"""
end
for icon <- @solid_icons do
@external_resource Path.join(@solid_path, "#{icon}.svg")
func_name =
icon
|> String.replace("-", "_")
|> Kernel.<>("_solid")
|> String.to_atom()
@doc false
attr :class, :any, default: "w-6 h-6"
attr :rest, :global
def unquote(func_name)(assigns) do
assigns = assign(assigns, :icon_path, "solid/#{unquote(icon)}.svg")
render_icon(assigns)
end
end
# Inline Icons
attr :rest, :global, default: %{class: "w-6 h-6"}
attr :rest, :global, default: %{class: "size-6"}
def oban_pro_logo(assigns) do
~H"""
@ -116,7 +73,7 @@ defmodule Oban.Web.Components.Icons do
"""
end
attr :rest, :global, default: %{class: "w-6 h-6"}
attr :rest, :global, default: %{class: "size-6"}
def spinner(assigns) do
~H"""
@ -126,37 +83,4 @@ defmodule Oban.Web.Components.Icons do
</svg>
"""
end
defp render_icon(assigns) do
icon_url = icon_url(assigns.icon_path)
class = normalize_class(assigns.class)
assigns = assigns |> assign(:icon_url, icon_url) |> assign(:normalized_class, class)
~H"""
<span
class={["block bg-current", @normalized_class]}
style={"mask-image: url(#{@icon_url}); -webkit-mask-image: url(#{@icon_url}); mask-size: contain; mask-repeat: no-repeat; mask-position: center;"}
aria-hidden="true"
{@rest}
/>
"""
end
defp icon_url(path) do
case Process.get(:routing) do
{socket, prefix} ->
Phoenix.VerifiedRoutes.unverified_path(
socket,
socket.router,
"#{prefix}/icons/#{path}"
)
_ ->
"/icons/#{path}"
end
end
defp normalize_class(class) when is_binary(class), do: class
defp normalize_class(class) when is_list(class), do: class
defp normalize_class(nil), do: "w-6 h-6"
end

View file

@ -121,7 +121,7 @@ defmodule Oban.Web.Layouts do
<div class="p-4">
<div class="flex items-center">
<div class="flex-shrink-0 text-green-400">
<Icons.check_circle />
<Icons.icon name="icon-check-circle" />
</div>
<div class="ml-3 w-0 flex-1">
<p class="text-sm leading-5 font-medium text-gray-900 dark:text-gray-100">
@ -133,7 +133,7 @@ defmodule Oban.Web.Layouts do
phx-click="lv:clear-flash"
class="inline-flex text-gray-400 dark:text-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 focus:text-gray-500 transition ease-in-out duration-150"
>
<Icons.x_mark class="w-5 h-5" />
<Icons.icon name="icon-x-mark" class="w-5 h-5" />
</button>
</div>
</div>

View file

@ -41,7 +41,10 @@ defmodule Oban.Web.SidebarComponents do
phx-click={toggle(@name)}
phx-hook="Tippy"
>
<Icons.chevron_right class="w-4 h-4 mt-1 mr-1 transition-transform rotate-90" />
<Icons.icon
name="icon-chevron-right"
class="w-4 h-4 mt-1 mr-1 transition-transform rotate-90"
/>
</button>
<h3 class="dark:text-gray-200 font-bold">{String.capitalize(@name)}</h3>

View file

@ -22,9 +22,9 @@ defmodule Oban.Web.SortComponent do
type="button"
>
<%= if @params.sort_dir == "asc" do %>
<Icons.bars_arrow_down class="w-4 h-4" />
<Icons.icon name="icon-bars-arrow-down" class="w-4 h-4" />
<% else %>
<Icons.bars_arrow_up class="w-4 h-4" />
<Icons.icon name="icon-bars-arrow-up" class="w-4 h-4" />
<% end %>
<span class="ml-1 block capitalize">
{String.replace(@params.sort_by, "_", " ")}
@ -71,7 +71,7 @@ defmodule Oban.Web.SortComponent do
role="option"
>
<%= if @value == @selected do %>
<Icons.check class="w-4 h-4 text-blue-500" />
<Icons.icon name="icon-check" class="w-4 h-4 text-blue-500" />
<% else %>
<span class="block w-4 h-4"></span>
<% end %>

View file

@ -45,10 +45,10 @@ defmodule Oban.Web.CronExpr do
defp describe_parsed(expression) do
with [min, hrs, dom, "*", dow] <- String.split(expression, " ", parts: 5),
{:ok, parsed_min} <- parse_field(min),
{:ok, parsed_hrs} <- parse_field(hrs),
{:ok, parsed_dom} <- parse_field(dom),
{:ok, parsed_dow} <- parse_field(dow, @days_of_week_translations) do
{:ok, parsed_min} <- parse_field(min, 0..59),
{:ok, parsed_hrs} <- parse_field(hrs, 0..23),
{:ok, parsed_dom} <- parse_field(dom, 1..31),
{:ok, parsed_dow} <- parse_field(dow, 0..7, @days_of_week_translations) do
combine_description(parsed_min, parsed_hrs, parsed_dom, parsed_dow)
else
_ -> nil
@ -57,11 +57,11 @@ defmodule Oban.Web.CronExpr do
# Field Parsing
defp parse_field(field, translations \\ %{}) do
defp parse_field(field, bounds, translations \\ %{}) do
parts =
field
|> String.split(",")
|> Enum.map(&parse_part(&1, translations))
|> Enum.map(&parse_part(&1, bounds, translations))
if Enum.any?(parts, &(&1 == :error)) do
:error
@ -70,37 +70,40 @@ defmodule Oban.Web.CronExpr do
end
end
defp parse_part("*", _translations), do: :wildcard
defp parse_part("*", _bounds, _translations), do: :wildcard
defp parse_part("*/" <> step, _translations) do
defp parse_part("*/" <> step, bounds, _translations) do
case Integer.parse(step) do
{num, ""} when num > 0 -> {:step, num}
{num, ""} when num > bounds.first and num <= bounds.last -> {:step, num}
_ -> :error
end
end
defp parse_part(part, translations) do
defp parse_part(part, bounds, translations) do
if String.contains?(part, "-") do
parse_range(part, translations)
parse_range(part, bounds, translations)
else
parse_value(part, translations)
parse_value(part, bounds, translations)
end
end
defp parse_range(part, translations) do
defp parse_range(part, bounds, translations) do
with [start_str, end_str] <- String.split(part, "-", parts: 2),
{:ok, start_val} <- translate_or_parse(start_str, translations),
{:ok, end_val} <- translate_or_parse(end_str, translations) do
{:ok, end_val} <- translate_or_parse(end_str, translations),
true <- start_val in bounds and end_val in bounds and end_val >= start_val do
{:range, start_val, end_val}
else
_ -> :error
end
end
defp parse_value(part, translations) do
case translate_or_parse(part, translations) do
{:ok, val} -> {:value, val}
:error -> :error
defp parse_value(part, bounds, translations) do
with {:ok, val} <- translate_or_parse(part, translations),
true <- val in bounds do
{:value, val}
else
_ -> :error
end
end

View file

@ -20,6 +20,7 @@ defmodule Oban.Web.DashboardLive do
page = resolve_page(params)
Process.put(:routing, {socket, prefix})
Process.put(:csp_nonce_style, csp_nonces.style)
socket =
socket

View file

@ -28,7 +28,7 @@ defmodule Oban.Web.Helpers do
def oban_path(route, params) when is_list(route) do
route
|> Enum.join("/")
|> Enum.map_join("/", &encode_segment/1)
|> oban_path(params)
end
@ -50,6 +50,10 @@ defmodule Oban.Web.Helpers do
end
end
defp encode_segment(segment) do
segment |> to_string() |> URI.encode(&URI.char_unreserved?/1)
end
@doc """
Toggle filterable params into a patch path.
"""

View file

@ -47,10 +47,15 @@ defmodule Oban.Web.ConnectivityComponent do
def render(assigns) do
~H"""
<div id="connectivity" data-title={@title} phx-hook="Tippy">
<Icons.bolt_circle :if={@status == :solitary} class="w-6 h-6 text-yellow-500" />
<Icons.bolt_slash :if={@status == :isolated} class="w-6 h-6 animate-pulse text-red-500" />
<Icons.exclamation_circle
<Icons.icon :if={@status == :solitary} name="icon-bolt-circle" class="w-6 h-6 text-yellow-500" />
<Icons.icon
:if={@status == :isolated}
name="icon-bolt-slash"
class="w-6 h-6 animate-pulse text-red-500"
/>
<Icons.icon
:if={@status == :disconnected}
name="icon-exclamation-circle"
class="w-6 h-6 animate-pulse text-red-500"
/>
</div>

View file

@ -22,7 +22,7 @@ defmodule Oban.Web.Crons.DetailComponent do
data-title="Back to crons"
phx-hook="Tippy"
>
<Icons.arrow_left class="w-5 h-5" />
<Icons.icon name="icon-arrow-left" class="w-5 h-5" />
<span class="text-lg font-bold ml-2">
{@cron.worker}
<span :if={show_name?(@cron)} class="font-normal text-gray-500 dark:text-gray-400">
@ -140,7 +140,7 @@ defmodule Oban.Web.Crons.DetailComponent do
<div class="px-3 py-6 border-t border-gray-200 dark:border-gray-700">
<h3 class="flex font-semibold mb-3 space-x-2 text-gray-400">
<Icons.pencil_square />
<Icons.icon name="icon-pencil-square" />
<span>Edit Configuration</span>
</h3>
@ -230,7 +230,7 @@ defmodule Oban.Web.Crons.DetailComponent do
class="text-xs text-gray-500 dark:text-gray-400 hover:underline"
>
Editing requires DynamicCron
<Icons.arrow_top_right_on_square class="w-3 h-3 inline-block" />
<Icons.icon name="icon-arrow-top-right-on-square" class="w-3 h-3 inline-block" />
</a>
<.save_button disabled={not @changed?} />
</div>
@ -257,7 +257,7 @@ defmodule Oban.Web.Crons.DetailComponent do
navigate={oban_path(:jobs, %{meta: [["cron_name"], @cron.name], state: "completed"})}
class="absolute right-4 top-4 flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900 dark:hover:text-blue-300 opacity-0 group-hover:opacity-100 transition-opacity"
>
View all jobs <Icons.arrow_right class="w-3 h-3" />
View all jobs <Icons.icon name="icon-arrow-right" class="w-3 h-3" />
</.link>
</div>
"""

View file

@ -11,7 +11,7 @@ defmodule Oban.Web.Crons.Helpers do
def state_icon(%{paused: true} = assigns) do
~H"""
<Icons.pause_circle class="w-5 h-5 text-gray-400" />
<Icons.icon name="icon-pause-circle" class="w-5 h-5 text-gray-400" />
"""
end
@ -21,21 +21,21 @@ defmodule Oban.Web.Crons.Helpers do
~H"""
<%= case @state do %>
<% "available" -> %>
<Icons.ellipsis_horizontal_circle class={["w-5 h-5", @color_class]} />
<Icons.icon name="icon-ellipsis-horizontal-circle" class={["w-5 h-5", @color_class]} />
<% "cancelled" -> %>
<Icons.x_circle class={["w-5 h-5", @color_class]} />
<Icons.icon name="icon-x-circle" class={["w-5 h-5", @color_class]} />
<% "completed" -> %>
<Icons.check_circle class={["w-5 h-5", @color_class]} />
<Icons.icon name="icon-check-circle" class={["w-5 h-5", @color_class]} />
<% "discarded" -> %>
<Icons.exclamation_circle class={["w-5 h-5", @color_class]} />
<Icons.icon name="icon-exclamation-circle" class={["w-5 h-5", @color_class]} />
<% "executing" -> %>
<Icons.play_circle class={["w-5 h-5", @color_class]} />
<Icons.icon name="icon-play-circle" class={["w-5 h-5", @color_class]} />
<% "retryable" -> %>
<Icons.arrow_path class={["w-5 h-5", @color_class]} />
<Icons.icon name="icon-arrow-path" class={["w-5 h-5", @color_class]} />
<% "scheduled" -> %>
<Icons.clock class={["w-5 h-5", @color_class]} />
<Icons.icon name="icon-clock" class={["w-5 h-5", @color_class]} />
<% _ -> %>
<Icons.minus_circle class="w-5 h-5 text-gray-400" />
<Icons.icon name="icon-minus-circle" class="w-5 h-5 text-gray-400" />
<% end %>
"""
end
@ -43,14 +43,14 @@ defmodule Oban.Web.Crons.Helpers do
@doc """
Converts a NaiveDateTime to unix milliseconds, returning empty string for nil.
"""
def maybe_to_unix(nil), do: ""
def maybe_to_unix(timestamp) do
def maybe_to_unix(timestamp) when is_struct(timestamp) do
timestamp
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_unix(:millisecond)
end
def maybe_to_unix(_timestamp), do: ""
def show_name?(%{dynamic?: true, name: name, worker: worker}), do: name != worker
def show_name?(_cron), do: false
end

View file

@ -48,7 +48,7 @@ defmodule Oban.Web.Crons.NewComponent do
phx-target={@myself}
aria-label="Close"
>
<Icons.x_mark class="h-6 w-6" />
<Icons.icon name="icon-x-mark" class="h-6 w-6" />
</button>
</div>

View file

@ -26,7 +26,7 @@ defmodule Oban.Web.Crons.TableComponent do
</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" />
<Icons.icon name="icon-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.

View file

@ -15,7 +15,7 @@ defmodule Oban.Web.HelpComponent do
phx-click={JS.toggle(to: "#help-menu")}
type="button"
>
<Icons.question_mark_circle />
<Icons.icon name="icon-question-mark-circle" />
</button>
<ul
@ -35,7 +35,7 @@ defmodule Oban.Web.HelpComponent do
rel="noopener noreferrer"
class="flex items-center space-x-2 w-full"
>
<Icons.arrow_top_right_on_square class="w-5 h-5" />
<Icons.icon name="icon-arrow-top-right-on-square" class="w-5 h-5" />
<span class="text-gray-800 dark:text-gray-200">Documentation</span>
</a>
</li>
@ -45,7 +45,7 @@ defmodule Oban.Web.HelpComponent do
phx-click={JS.hide(to: "#help-menu") |> JS.exec("data-shortcut", to: "#shortcuts")}
role="option"
>
<Icons.command_line class="w-5 h-5" />
<Icons.icon name="icon-command-line" class="w-5 h-5" />
<span class="text-gray-800 dark:text-gray-200">Keyboard shortcuts</span>
</li>
</ul>

View file

@ -66,7 +66,7 @@ defmodule Oban.Web.InstancesComponent do
phx-value-name={name}
>
<%= if name == @active do %>
<Icons.check class="w-4 h-4 text-blue-500" />
<Icons.icon name="icon-check" class="w-4 h-4 text-blue-500" />
<% else %>
<span class="block w-4 h-4"></span>
<% end %>

View file

@ -56,10 +56,13 @@ defmodule Oban.Web.Jobs.ChartComponent do
phx-click={toggle_chart(@myself)}
phx-hook="Tippy"
>
<Icons.chevron_right class={[
"w-5 h-5 mr-2 transition-transform",
if(@visible, do: "rotate-90")
]} />
<Icons.icon
name="icon-chevron-right"
class={[
"w-5 h-5 mr-2 transition-transform",
if(@visible, do: "rotate-90")
]}
/>
</button>
<h3 class="text-base font-semibold">
@ -89,7 +92,7 @@ defmodule Oban.Web.Jobs.ChartComponent do
target={@myself}
title="Change metric series"
>
<Icons.chart_bar_square />
<Icons.icon name="icon-chart-bar-square" />
</Core.dropdown_button>
<Core.dropdown_button
@ -100,7 +103,7 @@ defmodule Oban.Web.Jobs.ChartComponent do
target={@myself}
title="Change slice period"
>
<Icons.clock />
<Icons.icon name="icon-clock" />
</Core.dropdown_button>
<Core.dropdown_button
@ -111,7 +114,7 @@ defmodule Oban.Web.Jobs.ChartComponent do
target={@myself}
title="Change metric grouping"
>
<Icons.rectangle_group />
<Icons.icon name="icon-rectangle-group" />
</Core.dropdown_button>
<Core.dropdown_button
@ -122,7 +125,7 @@ defmodule Oban.Web.Jobs.ChartComponent do
target={@myself}
title="Change percentile"
>
<Icons.percent_square />
<Icons.icon name="icon-percent-square" />
</Core.dropdown_button>
</div>
</div>

View file

@ -54,7 +54,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
phx-hook="HistoryBack"
type="button"
>
<Icons.arrow_left class="w-5 h-5" />
<Icons.icon name="icon-arrow-left" class="w-5 h-5" />
<span class="text-lg font-bold ml-2">{job_title(@job)}</span>
</button>
@ -64,6 +64,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
<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" />
@ -109,7 +110,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
label="Edit"
color="violet"
tooltip="Edit this job"
disabled={not editable?(@job)}
disabled={executing?(@job)}
phx-click={scroll_to_edit()}
/>
</div>
@ -223,7 +224,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
Workflow
</span>
<span class="text-base text-gray-800 dark:text-gray-200 flex items-center">
<Icons.rectangle_group class="w-4 h-4 mr-1.5 text-violet-500" />
<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>
@ -241,7 +242,8 @@ defmodule Oban.Web.Jobs.DetailComponent do
phx-click="toggle-diagnostics"
phx-target={@myself}
>
<Icons.chevron_right
<Icons.icon
name="icon-chevron-right"
id="diagnostics-chevron"
class={["w-5 h-5 transition-transform", if(@diagnostics_open?, do: "rotate-90")]}
/>
@ -324,7 +326,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
phx-hook="Tippy"
phx-click={copy_to_clipboard(@diagnostics["info"]["current_stacktrace"])}
>
<Icons.clipboard class="w-4 h-4" />
<Icons.icon name="icon-clipboard" class="w-4 h-4" />
</button>
</div>
<%= if @diagnostics["info"]["current_stacktrace"] do %>
@ -345,7 +347,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
</div>
<% else %>
<div class="flex items-center space-x-2 px-2 text-gray-400 dark:text-gray-500">
<Icons.clock class="w-5 h-5" />
<Icons.icon name="icon-clock" class="w-5 h-5" />
<span class="text-sm">
<%= if executing?(@job) do %>
Waiting for diagnostics...
@ -374,7 +376,8 @@ defmodule Oban.Web.Jobs.DetailComponent do
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.chevron_right
<Icons.icon
name="icon-chevron-right"
id="errors-chevron"
class={["w-5 h-5 transition-transform", if(Enum.any?(@job.errors), do: "rotate-90")]}
/>
@ -440,7 +443,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
)
]}
>
<Icons.chevron_left class="w-5 h-5" />
<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)}
@ -460,7 +463,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
)
]}
>
<Icons.chevron_right class="w-5 h-5" />
<Icons.icon name="icon-chevron-right" class="w-5 h-5" />
</button>
</div>
</div>
@ -468,7 +471,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
<.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.check_circle class="w-5 h-5" />
<Icons.icon name="icon-check-circle" class="w-5 h-5" />
<span class="text-sm">No errors recorded</span>
</div>
<% end %>
@ -482,24 +485,25 @@ defmodule Oban.Web.Jobs.DetailComponent do
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.chevron_right
<Icons.icon
name="icon-chevron-right"
id="edit-chevron"
class={["w-5 h-5 transition-transform", if(editable?(@job), do: "rotate-90")]}
class={["w-5 h-5 transition-transform", if(not executing?(@job), do: "rotate-90")]}
/>
<span class="font-semibold">Edit Job</span>
<span
:if={not editable?(@job)}
:if={executing?(@job)}
id="edit-hint"
class="flex items-center"
data-title="Only available, scheduled, and retryable jobs can be edited"
data-title="Executing jobs can't be edited"
phx-hook="Tippy"
>
<Icons.info_circle class="w-4 h-4 text-gray-400" />
<Icons.icon name="icon-info-circle" class="w-4 h-4 text-gray-400" />
</span>
</button>
<div id="edit-content" class={["mt-3", unless(editable?(@job), do: "hidden")]}>
<fieldset disabled={not editable?(@job)}>
<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"
@ -567,7 +571,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
</button>
<button
type="submit"
disabled={not @edit_changed?}
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
@ -626,7 +630,11 @@ defmodule Oban.Web.Jobs.DetailComponent do
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.chevron_right id="job-data-chevron" class="w-5 h-5 transition-transform rotate-90" />
<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>
@ -645,7 +653,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
phx-hook="Tippy"
phx-click={copy_to_clipboard(format_args(@job, @resolver))}
>
<Icons.clipboard class="w-4 h-4" />
<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>
@ -664,7 +672,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
phx-hook="Tippy"
phx-click={copy_to_clipboard(format_meta(@job, @resolver))}
>
<Icons.clipboard class="w-4 h-4" />
<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>
@ -688,12 +696,39 @@ defmodule Oban.Web.Jobs.DetailComponent do
phx-hook="Tippy"
phx-click={copy_to_clipboard(format_recorded(@job, @resolver))}
>
<Icons.clipboard class="w-4 h-4" />
<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>
"""
@ -778,14 +813,16 @@ defmodule Oban.Web.Jobs.DetailComponent do
def handle_event("save-job", params, socket) do
job = socket.assigns.job
changes =
params
|> parse_edit_params(job)
|> Enum.reject(fn {_key, val} -> is_nil(val) end)
|> Map.new()
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})
if map_size(changes) > 0 do
send(self(), {:update_job, job, changes})
end
end
{:noreply, assign(socket, edit_changed?: false)}
@ -798,12 +835,7 @@ defmodule Oban.Web.Jobs.DetailComponent do
end
defp format_meta(%{meta: meta} = job, resolver) do
job =
if meta["recorded"] do
%{job | meta: Map.delete(meta, "return")}
else
job
end
job = %{job | meta: Map.drop(meta, ["return", "signal"])}
Resolver.call_with_fallback(resolver, :format_job_meta, [job])
end
@ -821,6 +853,41 @@ defmodule Oban.Web.Jobs.DetailComponent do
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
@ -919,10 +986,6 @@ defmodule Oban.Web.Jobs.DetailComponent do
JS.dispatch("phx:copy-to-clipboard", detail: %{text: text})
end
defp editable?(%{state: state}) do
state in ~w(scheduled retryable available)
end
defp executing?(%{state: state}), do: state == "executing"
defp format_bytes(nil), do: ""

View file

@ -11,7 +11,11 @@ defmodule Oban.Web.Jobs.HistoryChartComponent do
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_section()}
>
<Icons.chevron_right id="history-chevron" class="w-5 h-5 transition-transform rotate-90" />
<Icons.icon
name="icon-chevron-right"
id="history-chevron"
class="w-5 h-5 transition-transform rotate-90"
/>
<span class="font-semibold">Recent Executions</span>
</button>
@ -28,7 +32,7 @@ defmodule Oban.Web.Jobs.HistoryChartComponent do
navigate={all_jobs_path(@job)}
class="absolute right-4 top-4 flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900 dark:hover:text-blue-300 opacity-0 group-hover:opacity-100 transition-opacity"
>
View all jobs <Icons.arrow_right class="w-3 h-3" />
View all jobs <Icons.icon name="icon-arrow-right" class="w-3 h-3" />
</.link>
</div>
</div>

View file

@ -43,7 +43,7 @@ defmodule Oban.Web.Jobs.NewComponent do
phx-target={@myself}
aria-label="Close"
>
<Icons.x_mark class="h-6 w-6" />
<Icons.icon name="icon-x-mark" class="h-6 w-6" />
</button>
</div>

View file

@ -49,7 +49,7 @@ defmodule Oban.Web.Jobs.TableComponent do
<div :if={Enum.empty?(@jobs)} class="text-lg text-center py-12">
<div class="flex items-center justify-center space-x-2 text-gray-600 dark:text-gray-300">
<Icons.no_symbol /> <span>No jobs match the current set of filters.</span>
<Icons.icon name="icon-no-symbol" /> <span>No jobs match the current set of filters.</span>
</div>
<p :if={is_integer(@query_limit)} class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Filtering limited to latest {integer_to_delimited(@query_limit)} jobs. See <a
@ -111,16 +111,18 @@ defmodule Oban.Web.Jobs.TableComponent do
</div>
<div class="ml-auto flex items-center space-x-1">
<Icons.life_buoy
<Icons.icon
:if={Map.has_key?(@job.meta, "rescued")}
name="icon-life-buoy"
class="h-5 w-5 text-gray-500 dark:text-gray-300"
id={"job-rescued-#{assigns.job.id}"}
phx-hook="Tippy"
data-title="Rescued by lifeline"
/>
<Icons.crossbones_circle_solid
<Icons.icon
:if={orphaned?(@job, @producers)}
name="icon-crossbones-circle-solid"
class="h-5 w-5 text-gray-500 dark:text-gray-300"
id={"job-orphaned-#{assigns.job.id}"}
phx-hook="Tippy"

View file

@ -102,7 +102,7 @@ defmodule Oban.Web.Jobs.TimelineComponent do
defp state_icon(%{icon: :pending} = assigns) do
~H"""
<Icons.ellipsis_horizontal_circle class="w-5 h-5" />
<Icons.icon name="icon-ellipsis-horizontal-circle" class="w-5 h-5" />
"""
end
@ -114,7 +114,7 @@ defmodule Oban.Web.Jobs.TimelineComponent do
defp state_icon(%{icon: :done} = assigns) do
~H"""
<Icons.check_circle class="w-5 h-5" />
<Icons.icon name="icon-check-circle" class="w-5 h-5" />
"""
end

View file

@ -133,7 +133,7 @@ defmodule Oban.Web.Queues.DetailComponent do
phx-hook="HistoryBack"
type="button"
>
<Icons.arrow_left class="w-5 h-5" />
<Icons.icon name="icon-arrow-left" class="w-5 h-5" />
<span class="text-lg capitalize font-bold ml-2">{@queue} Queue</span>
</button>
@ -210,7 +210,7 @@ defmodule Oban.Web.Queues.DetailComponent do
navigate={oban_path(:jobs, %{queues: @queue, state: "completed"})}
class="absolute right-4 top-4 flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900 dark:hover:text-blue-300 opacity-0 group-hover:opacity-100 transition-opacity"
>
View all jobs <Icons.arrow_right class="w-3 h-3" />
View all jobs <Icons.icon name="icon-arrow-right" class="w-3 h-3" />
</.link>
</div>
"""
@ -339,7 +339,8 @@ defmodule Oban.Web.Queues.DetailComponent do
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_instances(@myself)}
>
<Icons.chevron_right
<Icons.icon
name="icon-chevron-right"
id="instances-chevron"
class={["w-5 h-5 transition-transform", if(@instances_open?, do: "rotate-90")]}
/>
@ -421,7 +422,8 @@ defmodule Oban.Web.Queues.DetailComponent do
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_config(@myself)}
>
<Icons.chevron_right
<Icons.icon
name="icon-chevron-right"
id="config-chevron"
class={["w-5 h-5 transition-transform", if(@config_open?, do: "rotate-90")]}
/>
@ -437,7 +439,7 @@ defmodule Oban.Web.Queues.DetailComponent do
phx-submit="local-submit"
>
<h3 class="flex items-center mb-4">
<Icons.map_pin class="w-5 h-5 mr-1 text-gray-500" />
<Icons.icon name="icon-map-pin" class="w-5 h-5 mr-1 text-gray-500" />
<span class="text-base font-medium">Local Limit</span>
</h3>
@ -467,7 +469,7 @@ defmodule Oban.Web.Queues.DetailComponent do
>
<div class="flex items-center justify-between mb-4">
<h3 class="flex items-center">
<Icons.globe class="w-5 h-5 mr-1 text-gray-500" />
<Icons.icon name="icon-globe" class="w-5 h-5 mr-1 text-gray-500" />
<span class="text-base font-medium">Global Limit</span>
<span
id="global-limit-info"
@ -475,7 +477,10 @@ defmodule Oban.Web.Queues.DetailComponent do
phx-hook="Tippy"
class="ml-1"
>
<Icons.info_circle class="w-4 h-4 text-gray-400 dark:text-gray-500" />
<Icons.icon
name="icon-info-circle"
class="w-4 h-4 text-gray-400 dark:text-gray-500"
/>
</span>
</h3>
@ -565,7 +570,7 @@ defmodule Oban.Web.Queues.DetailComponent do
class="text-xs text-gray-500 dark:text-gray-400 hover:underline"
>
Requires Smart Engine
<Icons.arrow_top_right_on_square class="w-3 h-3 inline-block" />
<Icons.icon name="icon-arrow-top-right-on-square" class="w-3 h-3 inline-block" />
</a>
</div>
</form>
@ -579,7 +584,7 @@ defmodule Oban.Web.Queues.DetailComponent do
>
<div class="flex items-center justify-between mb-4">
<h3 class="flex items-center">
<Icons.arrow_trending_down class="w-5 h-5 mr-1 text-gray-500" />
<Icons.icon name="icon-arrow-trending-down" class="w-5 h-5 mr-1 text-gray-500" />
<span class="text-base font-medium">Rate Limit</span>
<span
id="rate-limit-info"
@ -587,7 +592,10 @@ defmodule Oban.Web.Queues.DetailComponent do
phx-hook="Tippy"
class="ml-1"
>
<Icons.info_circle class="w-4 h-4 text-gray-400 dark:text-gray-500" />
<Icons.icon
name="icon-info-circle"
class="w-4 h-4 text-gray-400 dark:text-gray-500"
/>
</span>
</h3>
@ -677,7 +685,7 @@ defmodule Oban.Web.Queues.DetailComponent do
class="text-xs text-gray-500 dark:text-gray-400 hover:underline"
>
Requires Smart Engine
<Icons.arrow_top_right_on_square class="w-3 h-3 inline-block" />
<Icons.icon name="icon-arrow-top-right-on-square" class="w-3 h-3 inline-block" />
</a>
</div>
</form>
@ -913,13 +921,13 @@ defmodule Oban.Web.Queues.DetailComponent do
class={"#{if @enabled, do: "opacity-0 ease-out duration-100", else: "opacity-100 ease-in duration-200"} absolute inset-0 h-full w-full flex items-center justify-center transition-opacity"}
aria-hidden="true"
>
<Icons.x_mark class="h-3 w-3 text-gray-400" />
<Icons.icon name="icon-x-mark" class="h-3 w-3 text-gray-400" />
</span>
<span
class={"#{if @enabled, do: "opacity-100 ease-in duration-200", else: "opacity-0 ease-out duration-100"} absolute inset-0 h-full w-full flex items-center justify-center transition-opacity"}
aria-hidden="true"
>
<Icons.check class="h-3 w-3 text-blue-500" />
<Icons.icon name="icon-check" class="h-3 w-3 text-blue-500" />
</span>
</span>
</button>
@ -930,7 +938,7 @@ defmodule Oban.Web.Queues.DetailComponent do
~H"""
<div class="flex items-center justify-end mt-4 space-x-2">
<%= if @locked do %>
<Icons.lock_closed class="w-5 h-5 text-gray-600 dark:text-gray-400" />
<Icons.icon name="icon-lock-closed" class="w-5 h-5 text-gray-600 dark:text-gray-400" />
<% end %>
<button

View file

@ -115,13 +115,13 @@ defmodule Oban.Web.Queues.DetailInstanceComponent do
:if={@terminating?}
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
>
<Icons.power class="w-3 h-3 mr-0.5" /> Stopping
<Icons.icon name="icon-power" class="w-3 h-3 mr-0.5" /> Stopping
</span>
<span
:if={@paused and not @terminating?}
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"
>
<Icons.pause_circle class="w-3 h-3 mr-0.5" /> Paused
<Icons.icon name="icon-pause-circle" class="w-3 h-3 mr-0.5" /> Paused
</span>
"""
end
@ -147,9 +147,9 @@ defmodule Oban.Web.Queues.DetailInstanceComponent do
type="button"
>
<%= if @paused do %>
<Icons.play_circle class="w-5 h-5" />
<Icons.icon name="icon-play-circle" class="w-5 h-5" />
<% else %>
<Icons.pause_circle class="w-5 h-5" />
<Icons.icon name="icon-pause-circle" class="w-5 h-5" />
<% end %>
</button>
"""
@ -168,7 +168,7 @@ defmodule Oban.Web.Queues.DetailInstanceComponent do
phx-target={@target}
type="button"
>
<Icons.pencil_square class="w-5 h-5" />
<Icons.icon name="icon-pencil-square" class="w-5 h-5" />
</button>
"""
end

View file

@ -23,7 +23,7 @@ defmodule Oban.Web.Queues.TableComponent do
</ul>
<div :if={Enum.empty?(@queues) and Enum.empty?(@checks)} class="py-16 px-6 text-center">
<Icons.queue_list class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<Icons.icon name="icon-queue-list" 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 queues</h3>
<p class="mt-2 text-base text-gray-500 dark:text-gray-400 max-w-md mx-auto">
Queues process jobs concurrently. They'll appear here once your Oban instance starts with queues configured.
@ -44,7 +44,7 @@ defmodule Oban.Web.Queues.TableComponent do
:if={Enum.empty?(@queues) and not Enum.empty?(@checks)}
class="flex items-center justify-center py-12 space-x-2 text-lg text-gray-600 dark:text-gray-300"
>
<Icons.no_symbol /> <span>No queues match the current filters.</span>
<Icons.icon name="icon-no-symbol" /> <span>No queues match the current filters.</span>
</div>
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
@ -115,22 +115,25 @@ defmodule Oban.Web.Queues.TableComponent do
<span class="w-14 text-left tabular pl-2">{exec}/{limit}</span>
</span>
<div class="w-14 flex items-center justify-start space-x-1 text-gray-400 dark:text-gray-500">
<Icons.globe
<Icons.icon
:if={Queue.global_limit?(@queue)}
name="icon-globe"
class="w-4 h-4"
data-title="Global limit"
id={"#{@queue.name}-has-global"}
phx-hook="Tippy"
/>
<Icons.arrow_trending_down
<Icons.icon
:if={Queue.rate_limit?(@queue)}
name="icon-arrow-trending-down"
class="w-4 h-4"
data-title="Rate limit"
id={"#{@queue.name}-has-rate"}
phx-hook="Tippy"
/>
<Icons.view_columns
<Icons.icon
:if={Queue.partitioned?(@queue)}
name="icon-view-columns"
class="w-4 h-4"
data-title="Partitioned"
id={"#{@queue.name}-has-partition"}
@ -203,24 +206,27 @@ defmodule Oban.Web.Queues.TableComponent do
</span>
<div class="w-20 pr-3 flex justify-center items-center space-x-1">
<Icons.pause_circle
<Icons.icon
:if={Queue.all_paused?(@queue)}
name="icon-pause-circle"
class="w-5 h-5"
data-title="All paused"
id={"#{@queue.name}-is-paused"}
phx-hook="Tippy"
rel="is-paused"
/>
<Icons.play_pause_circle
<Icons.icon
:if={Queue.any_paused?(@queue) and not Queue.all_paused?(@queue)}
name="icon-play-pause-circle"
class="w-5 h-5"
data-title="Some paused"
id={"#{@queue.name}-is-some-paused"}
phx-hook="Tippy"
rel="has-some-paused"
/>
<Icons.power
<Icons.icon
:if={Queue.terminating?(@queue)}
name="icon-power"
class="w-5 h-5"
data-title="Terminating"
id={"#{@queue.name}-is-terminating"}

View file

@ -36,7 +36,7 @@ defmodule Oban.Web.RefreshComponent do
phx-click={JS.toggle(to: "#refresh-menu")}
type="button"
>
<Icons.arrow_path_rounded />
<Icons.icon name="icon-arrow-path-rounded" />
<span class="ml-1 leading-6 text-sm">{:proplists.get_value(@refresh, @options)}</span>
</button>
@ -78,7 +78,7 @@ defmodule Oban.Web.RefreshComponent do
phx-value-interval={@value}
>
<%= if @value == @refresh do %>
<Icons.check class="w-5 h-5" />
<Icons.icon name="icon-check" class="w-5 h-5" />
<% else %>
<span class="block w-5 h-5"></span>
<% end %>

View file

@ -43,7 +43,7 @@ defmodule Oban.Web.SearchComponent do
<%= if @loading do %>
<Icons.spinner class="w-5 h-5 text-gray-200 animate-spin dark:text-gray-600 fill-violet-500" />
<% else %>
<Icons.magnifying_glass class="w-5 h-5 text-gray-500" />
<Icons.icon name="icon-magnifying-glass" class="w-5 h-5 text-gray-500" />
<% end %>
</div>
@ -84,7 +84,7 @@ defmodule Oban.Web.SearchComponent do
phx-click="clear"
type="reset"
>
<Icons.x_circle class="w-5 h-5" />
<Icons.icon name="icon-x-circle" class="w-5 h-5" />
</button>
<nav
@ -102,7 +102,7 @@ defmodule Oban.Web.SearchComponent do
/>
<div :if={Enum.empty?(@suggestions)} class="w-full flex items-center space-x-2 p-1">
<Icons.exclamation_circle class="w-5 h-5 text-gray-400" />
<Icons.icon name="icon-exclamation-circle" class="w-5 h-5 text-gray-400" />
<span class="text-gray-700">No suggestions matching <b>"{@buffer}"</b></span>
</div>
</div>
@ -113,7 +113,7 @@ defmodule Oban.Web.SearchComponent do
title="Read the filtering and searching guide"
target="_blank"
>
<Icons.info_circle class="w-4 h-4" />
<Icons.icon name="icon-info-circle" class="w-4 h-4" />
<span class="text-xs">Filtering Tips</span>
</a>
</nav>
@ -139,7 +139,7 @@ defmodule Oban.Web.SearchComponent do
phx-value-terms={@terms}
phx-target="#search"
>
<Icons.x_mark class="w-5 h-5" />
<Icons.icon name="icon-x-mark" class="w-5 h-5" />
</button>
</div>
"""

View file

@ -33,7 +33,7 @@ defmodule Oban.Web.ShortcutsComponent do
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
aria-label="close"
>
<Icons.x_mark />
<Icons.icon name="icon-x-mark" />
</button>
<div>

View file

@ -71,11 +71,11 @@ defmodule Oban.Web.ThemeComponent do
~H"""
<%= case @theme do %>
<% "light" -> %>
<Icons.sun />
<Icons.icon name="icon-sun" />
<% "dark" -> %>
<Icons.moon />
<Icons.icon name="icon-moon" />
<% "system" -> %>
<Icons.computer_desktop />
<Icons.icon name="icon-computer-desktop" />
<% end %>
"""
end

View file

@ -64,7 +64,10 @@ defmodule Oban.Web.Workflows.DetailComponent do
<% else %>
<div class="flex items-center justify-center py-16">
<div class="text-center">
<Icons.rectangle_group class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<Icons.icon
name="icon-rectangle-group"
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">
Workflow not found
</h3>
@ -97,7 +100,7 @@ defmodule Oban.Web.Workflows.DetailComponent do
phx-hook="HistoryBack"
type="button"
>
<Icons.arrow_left class="w-5 h-5" />
<Icons.icon name="icon-arrow-left" class="w-5 h-5" />
<span class="text-lg font-bold ml-2">{@workflow.name || @workflow.id}</span>
</button>
@ -142,7 +145,7 @@ defmodule Oban.Web.Workflows.DetailComponent do
defp parent_breadcrumb(assigns) do
~H"""
<div class="flex items-center ml-3 text-sm text-gray-500 dark:text-gray-400">
<Icons.arrow_turn_down_right class="w-4 h-4 mr-1" />
<Icons.icon name="icon-arrow-turn-down-right" class="w-4 h-4 mr-1" />
<span>sub-workflow of</span>
<.link
navigate={oban_path([:workflows, @parent.id])}
@ -233,10 +236,13 @@ defmodule Oban.Web.Workflows.DetailComponent do
phx-click="toggle-graph"
phx-target={@myself}
>
<Icons.chevron_right class={[
"w-5 h-5 transition-transform",
@graph_open? && "rotate-90"
]} />
<Icons.icon
name="icon-chevron-right"
class={[
"w-5 h-5 transition-transform",
@graph_open? && "rotate-90"
]}
/>
<span class="font-semibold">Workflow Graph</span>
</button>
@ -419,10 +425,13 @@ defmodule Oban.Web.Workflows.DetailComponent do
phx-click="toggle-subs"
phx-target={@myself}
>
<Icons.chevron_right class={[
"w-5 h-5 transition-transform",
@subs_open? && "rotate-90"
]} />
<Icons.icon
name="icon-chevron-right"
class={[
"w-5 h-5 transition-transform",
@subs_open? && "rotate-90"
]}
/>
<span class="font-semibold">
Sub-workflows
<span class="text-gray-400 font-normal">
@ -522,17 +531,17 @@ defmodule Oban.Web.Workflows.DetailComponent do
<span class="inline-flex">
<%= case @state do %>
<% "executing" -> %>
<Icons.play_circle class="w-5 h-5 text-emerald-400" />
<Icons.icon name="icon-play-circle" class="w-5 h-5 text-emerald-400" />
<% "completed" -> %>
<Icons.check_circle class="w-5 h-5 text-cyan-400" />
<Icons.icon name="icon-check-circle" class="w-5 h-5 text-cyan-400" />
<% "retryable" -> %>
<Icons.arrow_path class="w-5 h-5 text-yellow-400" />
<Icons.icon name="icon-arrow-path" class="w-5 h-5 text-yellow-400" />
<% "cancelled" -> %>
<Icons.x_circle class="w-5 h-5 text-violet-400" />
<Icons.icon name="icon-x-circle" class="w-5 h-5 text-violet-400" />
<% "discarded" -> %>
<Icons.exclamation_circle class="w-5 h-5 text-rose-400" />
<Icons.icon name="icon-exclamation-circle" class="w-5 h-5 text-rose-400" />
<% _ -> %>
<Icons.minus_circle class="w-5 h-5 text-gray-400" />
<Icons.icon name="icon-minus-circle" class="w-5 h-5 text-gray-400" />
<% end %>
</span>
"""

View file

@ -21,7 +21,10 @@ defmodule Oban.Web.Workflows.TableComponent do
</ul>
<div :if={Enum.empty?(@workflows)} class="py-16 px-6 text-center">
<Icons.rectangle_group class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<Icons.icon
name="icon-rectangle-group"
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 workflows</h3>
<p class="mt-2 text-base text-gray-500 dark:text-gray-400 max-w-md mx-auto">
Workflows coordinate jobs with dependencies. They'll appear here once jobs with workflow metadata are enqueued.
@ -249,15 +252,15 @@ defmodule Oban.Web.Workflows.TableComponent do
<span data-title={status_title(@state)} id={"workflow-state-#{@id}"} phx-hook="Tippy">
<%= case @state do %>
<% :executing -> %>
<Icons.play_circle class="w-5 h-5 text-emerald-400" />
<Icons.icon name="icon-play-circle" class="w-5 h-5 text-emerald-400" />
<% :completed -> %>
<Icons.check_circle class="w-5 h-5 text-cyan-400" />
<Icons.icon name="icon-check-circle" class="w-5 h-5 text-cyan-400" />
<% :cancelled -> %>
<Icons.x_circle class="w-5 h-5 text-violet-400" />
<Icons.icon name="icon-x-circle" class="w-5 h-5 text-violet-400" />
<% :discarded -> %>
<Icons.exclamation_circle class="w-5 h-5 text-rose-400" />
<Icons.icon name="icon-exclamation-circle" class="w-5 h-5 text-rose-400" />
<% _ -> %>
<Icons.minus_circle class="w-5 h-5 text-gray-400" />
<Icons.icon name="icon-minus-circle" class="w-5 h-5 text-gray-400" />
<% end %>
</span>
"""

View file

@ -71,7 +71,7 @@ defmodule Oban.Web.CronsPage do
"text-gray-400 dark:text-gray-500 border-gray-200 dark:border-gray-800 cursor-not-allowed opacity-50"
]}
>
<Icons.plus_circle class="mr-1 h-4 w-4" /> New
<Icons.icon name="icon-plus-circle" class="mr-1 h-4 w-4" /> New
</.link>
</div>
</div>

View file

@ -89,7 +89,7 @@ defmodule Oban.Web.JobsPage do
click="cancel-jobs"
target={@myself}
>
<:icon><Icons.x_circle class="w-5 h-5" /></:icon>
<:icon><Icons.icon name="icon-x-circle" class="w-5 h-5" /></:icon>
<:title>Cancel Jobs</:title>
</Core.action_button>
@ -99,7 +99,7 @@ defmodule Oban.Web.JobsPage do
click="retry-jobs"
target={@myself}
>
<:icon><Icons.arrow_right_circle class="w-5 h-5" /></:icon>
<:icon><Icons.icon name="icon-arrow-right-circle" class="w-5 h-5" /></:icon>
<:title>Retry Jobs</:title>
</Core.action_button>
@ -109,7 +109,7 @@ defmodule Oban.Web.JobsPage do
click="retry-jobs"
target={@myself}
>
<:icon><Icons.arrow_right_circle class="w-5 h-5" /></:icon>
<:icon><Icons.icon name="icon-arrow-right-circle" class="w-5 h-5" /></:icon>
<:title>Run Jobs Now</:title>
</Core.action_button>
@ -120,7 +120,7 @@ defmodule Oban.Web.JobsPage do
target={@myself}
danger={true}
>
<:icon><Icons.trash class="w-5 h-5" /></:icon>
<:icon><Icons.icon name="icon-trash" class="w-5 h-5" /></:icon>
<:title>Delete Jobs</:title>
</Core.action_button>
</div>
@ -162,7 +162,7 @@ defmodule Oban.Web.JobsPage do
"text-gray-400 dark:text-gray-500 border-gray-200 dark:border-gray-800 cursor-not-allowed opacity-50"
]}
>
<Icons.plus_circle class="mr-1 h-4 w-4" /> New
<Icons.icon name="icon-plus-circle" class="mr-1 h-4 w-4" /> New
</.link>
</div>
</div>

View file

@ -56,7 +56,7 @@ defmodule Oban.Web.QueuesPage do
click="pause-queues"
target={@myself}
>
<:icon><Icons.pause_circle class="w-5 h-5" /></:icon>
<:icon><Icons.icon name="icon-pause-circle" class="w-5 h-5" /></:icon>
<:title>Pause Queues</:title>
</Core.action_button>
@ -66,7 +66,7 @@ defmodule Oban.Web.QueuesPage do
click="resume-queues"
target={@myself}
>
<:icon><Icons.play_circle class="w-5 h-5" /></:icon>
<:icon><Icons.icon name="icon-play-circle" class="w-5 h-5" /></:icon>
<:title>Resume Queues</:title>
</Core.action_button>
@ -77,7 +77,7 @@ defmodule Oban.Web.QueuesPage do
target={@myself}
danger={true}
>
<:icon><Icons.x_circle class="w-5 h-5" /></:icon>
<:icon><Icons.icon name="icon-x-circle" class="w-5 h-5" /></:icon>
<:title>Stop Queues</:title>
</Core.action_button>
</div>

View file

@ -121,7 +121,7 @@ defmodule Oban.Web.WorkflowsPage do
~H"""
<div class="flex flex-col items-center justify-center py-16 px-6">
<div class="flex items-center justify-center w-16 h-16 rounded-full bg-violet-100 dark:bg-violet-900/30 mb-6">
<Icons.rectangle_group class="w-8 h-8 text-violet-500 dark:text-violet-400" />
<Icons.icon name="icon-rectangle-group" class="w-8 h-8 text-violet-500 dark:text-violet-400" />
</div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3">
@ -136,21 +136,21 @@ defmodule Oban.Web.WorkflowsPage do
<ul class="text-left text-gray-600 dark:text-gray-400 space-y-3 mb-8">
<li class="flex items-start">
<Icons.check class="w-5 h-5 text-violet-500 mr-2 mt-0.5 shrink-0" />
<Icons.icon name="icon-check" class="w-5 h-5 text-violet-500 mr-2 mt-0.5 shrink-0" />
<span>
<span class="font-medium text-gray-700 dark:text-gray-300">Fully Distributed</span>
high availability and scalability across your infrastructure
</span>
</li>
<li class="flex items-start">
<Icons.check class="w-5 h-5 text-violet-500 mr-2 mt-0.5 shrink-0" />
<Icons.icon name="icon-check" class="w-5 h-5 text-violet-500 mr-2 mt-0.5 shrink-0" />
<span>
<span class="font-medium text-gray-700 dark:text-gray-300">Cascading Context</span>
pass cumulative context between jobs for seamless data flow
</span>
</li>
<li class="flex items-start">
<Icons.check class="w-5 h-5 text-violet-500 mr-2 mt-0.5 shrink-0" />
<Icons.icon name="icon-check" class="w-5 h-5 text-violet-500 mr-2 mt-0.5 shrink-0" />
<span>
<span class="font-medium text-gray-700 dark:text-gray-300">Nested Sub-Workflows</span>
compose hierarchically for better organization and reusability
@ -163,7 +163,7 @@ defmodule Oban.Web.WorkflowsPage do
target="_blank"
class="inline-flex items-center px-5 py-2.5 rounded-lg bg-violet-600 hover:bg-violet-700 text-white font-medium transition-colors"
>
Learn about Oban Pro <Icons.arrow_top_right_on_square class="w-4 h-4 ml-2" />
Learn about Oban Pro <Icons.icon name="icon-arrow-top-right-on-square" class="w-4 h-4 ml-2" />
</.link>
</div>
"""
@ -211,7 +211,7 @@ defmodule Oban.Web.WorkflowsPage do
)
true ->
workflows = WorkflowQuery.all_workflows(params, conf)
workflows = WorkflowQuery.all_workflows(conf, params)
limit = params.limit
assign(socket,

View file

@ -195,7 +195,11 @@ defmodule Oban.Web.CronQuery do
state: j.state,
scheduled_at: j.scheduled_at,
attempted_at: j.attempted_at,
finished_at: fragment("COALESCE(?, ?, ?)", j.completed_at, j.cancelled_at, j.discarded_at)
finished_at:
type(
fragment("COALESCE(?, ?, ?)", j.completed_at, j.cancelled_at, j.discarded_at),
:utc_datetime_usec
)
})
conf
@ -228,6 +232,8 @@ defmodule Oban.Web.CronQuery do
query = select(Oban.Pro.Cron, [c], {c.expression, c.worker, c.opts, c.name, true, c.paused})
Repo.all(conf, query)
else
[]
end
end
@ -278,7 +284,10 @@ defmodule Oban.Web.CronQuery do
attempted_at: o.attempted_at,
scheduled_at: o.scheduled_at,
finished_at:
fragment("COALESCE(?, ?, ?)", o.completed_at, o.cancelled_at, o.discarded_at)
type(
fragment("COALESCE(?, ?, ?)", o.completed_at, o.cancelled_at, o.discarded_at),
:utc_datetime_usec
)
},
select_merge: %{
rn: over(row_number(), partition_by: o.meta["cron_name"], order_by: [desc: o.id])

View file

@ -126,30 +126,32 @@ defmodule Oban.Web.WorkflowQuery do
end
end
defmacrop sub_workflow_parent_dep(sub_workflow_id, sup_workflow_id) do
defmacrop sub_workflow_parent_dep(prefix, sub_workflow_id, sup_workflow_id) do
quote do
fragment(
"""
(SELECT dep->>1
FROM oban_jobs j2,
FROM ?.oban_jobs j2,
LATERAL jsonb_array_elements(j2.meta->'deps') AS dep
WHERE j2.meta->>'workflow_id' = ?
AND jsonb_typeof(dep) = 'array'
AND dep->>0 = ?
LIMIT 1)
""",
identifier(^unquote(prefix)),
unquote(sub_workflow_id),
unquote(sup_workflow_id)
)
end
end
defmacrop sub_workflow_states(parent_id) do
defmacrop sub_workflow_states(prefix, parent_id) do
quote do
fragment(
"""
(SELECT COALESCE(array_agg(state), '{}') FROM oban_workflows WHERE parent_id = ?)
(SELECT COALESCE(array_agg(state), '{}') FROM ?.oban_workflows WHERE parent_id = ?)
""",
identifier(^unquote(prefix)),
unquote(parent_id)
)
end
@ -279,18 +281,19 @@ defmodule Oban.Web.WorkflowQuery do
# Querying
def all_workflows(params, conf) do
def all_workflows(conf, params) do
limit = Map.get(params, :limit, 10)
sort_by = Map.get(params, :sort_by, "inserted")
sort_dir = Map.get(params, :sort_dir, "desc")
dir = String.to_existing_atom(sort_dir)
prefix = conf.prefix
Workflow
|> where([wf], is_nil(wf.parent_id))
|> apply_filters(params)
|> apply_sort(sort_by, dir)
|> limit(^limit)
|> select([wf], {wf, sub_workflow_states(wf.id)})
|> select([wf], {wf, sub_workflow_states(prefix, wf.id)})
|> then(&Repo.all(conf, &1))
|> Enum.map(&build_workflow/1)
end
@ -446,6 +449,8 @@ defmodule Oban.Web.WorkflowQuery do
end
defp workflow_graph_subs(conf, workflow_id) do
prefix = conf.prefix
base_query =
Job
|> where([j], has_sup_workflow_id(j.meta, ^workflow_id))
@ -464,7 +469,7 @@ defmodule Oban.Web.WorkflowQuery do
workflow_name: s.workflow_name,
sub_name: s.sub_name,
state: s.state,
parent_dep: sub_workflow_parent_dep(s.workflow_id, ^workflow_id)
parent_dep: sub_workflow_parent_dep(prefix, s.workflow_id, ^workflow_id)
}
)

View file

@ -41,6 +41,13 @@ defmodule Oban.Web.Resolver do
|> inspect(charlists: :as_lists, pretty: true)
end
@impl true
def format_signal(signal, _job) do
signal
|> Oban.Web.Resolver.decode_signal()
|> inspect(charlists: :as_lists, pretty: true)
end
@impl true
def jobs_query_limit(_state), do: 100_000
@ -138,6 +145,7 @@ defmodule Oban.Web.Resolver do
| :scale_queues
| :stop_queues
| :update_crons
| :update_jobs
@type qualifier :: :args | :meta | :nodes | :queues | :tags | :workers
@ -234,6 +242,35 @@ defmodule Oban.Web.Resolver do
"""
@callback format_recorded(recorded :: term(), job :: Job.t()) :: iodata()
@doc """
Customize the formatting of awaitable signal payloads wherever they are displayed.
This callback is similar to `c:format_recorded/2`, but it accepts both the signal binary and
the job to help augment the output.
Note that you **must decode the signal binary** prior to inspecting it.
## Examples
Disable pretty printing and change the output width to 98 characters:
def format_signal(signal, _job) do
signal
|> Oban.Web.Resolver.decode_signal()
|> inspect(pretty: false, width: 98)
end
Decode the signal value without the `:safe` flag set, to allow decoding terms with unknown
atoms:
def format_signal(signal, _job) do
signal
|> Oban.Web.Resolver.decode_signal([])
|> inspect(pretty: false, width: 98)
end
"""
@callback format_signal(signal :: term(), job :: Job.t()) :: iodata()
@doc """
Extract the current user from a `Plug.Conn` when the dashboard mounts.
@ -283,6 +320,7 @@ defmodule Oban.Web.Resolver do
* `:scale_queues`
* `:stop_queues`
* `:update_crons`
* `:update_jobs`
Actions which aren't listed are considered disabled.
@ -411,6 +449,7 @@ defmodule Oban.Web.Resolver do
@optional_callbacks format_job_args: 1,
format_job_meta: 1,
format_recorded: 2,
format_signal: 2,
bulk_action_limit: 1,
hint_query_limit: 1,
jobs_query_limit: 1,
@ -451,6 +490,15 @@ defmodule Oban.Web.Resolver do
end
end
@doc """
Decode an awaitable signal payload from a compressed, base64 binary into proper terms.
Signals share the same wire format as recorded output, so this is a thin alias around
`decode_recorded/2` provided for documentation symmetry.
"""
@spec decode_signal(binary(), [:safe]) :: term()
def decode_signal(bin, opts \\ [:safe]), do: decode_recorded(bin, opts)
@doc false
def call_with_fallback(resolver, fun, args) when is_atom(fun) and is_list(args) do
resolver =
@ -482,6 +530,13 @@ defmodule Oban.Web.Resolver do
|> inspect(@inspect_opts)
end
@doc false
def format_signal(signal, _job) do
signal
|> decode_signal()
|> inspect(@inspect_opts)
end
@doc false
def resolve_user(_conn), do: nil

View file

@ -200,7 +200,6 @@ defmodule Oban.Web.Router do
get "/css-:md5", Oban.Web.Assets, :css, as: :oban_web_asset
get "/js-:md5", Oban.Web.Assets, :js, as: :oban_web_asset
get "/fonts/inter.woff2", Oban.Web.Assets, :font, as: :oban_web_font
get "/icons/*path", Oban.Web.Assets, :icon, as: :oban_web_icon
live "/", Oban.Web.DashboardLive, :home, route_opts
live "/:page", Oban.Web.DashboardLive, :index, route_opts

View file

@ -5,9 +5,9 @@ defmodule Oban.Web.Utils do
alias Oban.Repo
def has_crons?(conf), do: has_table?(conf, "oban_crons")
def has_crons?(conf), do: has_table?("oban_crons", conf)
def has_workflows?(conf), do: has_table?(conf, "oban_workflows")
def has_workflows?(conf), do: has_table?("oban_workflows", conf)
def has_pro? do
persistent_cache(:pro?, fn -> Code.ensure_loaded?(Oban.Pro) end)
@ -20,10 +20,13 @@ defmodule Oban.Web.Utils do
end
end
defp has_table?(conf, table_name) do
%{name: name, prefix: prefix} = conf
defp has_table?(_table_name, %{engine: Oban.Engines.Dolphin}), do: false
defp has_table?(_table_name, %{engine: Oban.Engines.Lite}), do: false
persistent_cache({:table?, name, table_name}, fn ->
defp has_table?(table_name, conf) do
%{name: oban_name, prefix: prefix} = conf
persistent_cache({:table?, oban_name, table_name}, fn ->
query =
from("tables")
|> put_query_prefix("information_schema")

View file

@ -2,7 +2,7 @@ defmodule Oban.Web.MixProject do
use Mix.Project
@source_url "https://github.com/oban-bg/oban_web"
@version "2.12.1"
@version "2.12.5"
def project do
[
@ -130,7 +130,7 @@ defmodule Oban.Web.MixProject do
defp oban_pro_dep do
if oban_repo_configured?() do
[{:oban_pro, "~> 1.7.0-rc.0", repo: :oban, only: [:test, :dev]}]
[{:oban_pro, "~> 1.7.0", repo: :oban, only: [:test, :dev]}]
else
[]
end

File diff suppressed because one or more lines are too long

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"/>
</svg>

Before

Width:  |  Height:  |  Size: 380 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 13.5V3.75m0 9.75a1.5 1.5 0 010 3m0-3a1.5 1.5 0 000 3m0 3.75V16.5m12-3V3.75m0 9.75a1.5 1.5 0 010 3m0-3a1.5 1.5 0 000 3m0 3.75V16.5m-6-9V3.75m0 3.75a1.5 1.5 0 010 3m0-3a1.5 1.5 0 000 3m0 9.75V10.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 373 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"/>
</svg>

Before

Width:  |  Height:  |  Size: 211 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3"/>
</svg>

Before

Width:  |  Height:  |  Size: 496 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"/>
</svg>

Before

Width:  |  Height:  |  Size: 339 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12.75 15l3-3m0 0l-3-3m3 3h-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>

Before

Width:  |  Height:  |  Size: 241 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"/>
</svg>

Before

Width:  |  Height:  |  Size: 211 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/>
</svg>

Before

Width:  |  Height:  |  Size: 302 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6L9 12.75l4.286-4.286a11.948 11.948 0 014.306 6.43l.776 2.898m0 0l3.182-5.511m-3.182 5.51l-5.511-3.181"/>
</svg>

Before

Width:  |  Height:  |  Size: 284 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m16.49 12 3.75 3.75m0 0-3.75 3.75m3.75-3.75H3.74V4.499" />
</svg>

Before

Width:  |  Height:  |  Size: 253 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4.5h14.25M3 9h9.75M3 13.5h9.75m4.5-4.5v12m0 0l-3.75-3.75M17.25 21L21 17.25"/>
</svg>

Before

Width:  |  Height:  |  Size: 253 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12"/>
</svg>

Before

Width:  |  Height:  |  Size: 250 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.317 6.066a.648.648 0 0 1 .337.703l-.683 3.517h3.404c.245 0 .467.147.568.375a.657.657 0 0 1-.096.688l-5.416 6.43a.613.613 0 0 1-.747.155.648.648 0 0 1-.338-.702l.683-3.518H7.625a.624.624 0 0 1-.568-.375.657.657 0 0 1 .096-.688l5.416-6.43a.613.613 0 0 1 .748-.155ZM21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 479 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.412 15.655 9.75 21.75l3.745-4.012M9.257 13.5H3.75l2.659-2.849m2.048-2.194L14.25 2.25 12 10.5h8.25l-4.707 5.043M8.457 8.457 3 3m5.457 5.457 7.086 7.086m0 0L21 21"/>
</svg>

Before

Width:  |  Height:  |  Size: 340 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zm6.75-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V15zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H16.5v-.008zm0 2.25h.008v.008H16.5V15z"/>
</svg>

Before

Width:  |  Height:  |  Size: 719 B

View file

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zM18.75 10.5h.008v.008h-.008V10.5z"/>
</svg>

Before

Width:  |  Height:  |  Size: 669 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"/>
</svg>

Before

Width:  |  Height:  |  Size: 326 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>

Before

Width:  |  Height:  |  Size: 235 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 197 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 203 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>

Before

Width:  |  Height:  |  Size: 226 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 201 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 202 B

Some files were not shown because too many files have changed in this diff Show more