Update Elixir dependencies (#194)

Updated packages:
- bandit: 1.10.3 → 1.10.4
- phoenix_live_view: 1.1.27 → 1.1.28
- phoenix_pubsub_redis: 3.0.1 → 3.1.0
- oban_met: 1.0.6 → 1.1.0 (vendored)
- oban_web: 2.11.8 → 2.12.1 (vendored)

All tests passing (8923 tests, 0 failures).

Reviewed-on: graham/towerops-web#194
This commit is contained in:
Graham McIntire 2026-03-27 17:13:12 -05:00 committed by graham
parent 30248504e2
commit 0399b30d21
139 changed files with 8489 additions and 2205 deletions

View file

@ -3,7 +3,7 @@
"absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.4", "f36999412fbd6a2339abb5b7e24a4cc9492bbc7909d5806deeef83b06f55c508", [: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", [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", "66617ee63b725256ca16264364148b10b19e2ecb177488cd6353584f2e6c1cf3"}, "absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.4", "f36999412fbd6a2339abb5b7e24a4cc9492bbc7909d5806deeef83b06f55c508", [: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", [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", "66617ee63b725256ca16264364148b10b19e2ecb177488cd6353584f2e6c1cf3"},
"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.9", "4f66fd46aecf969b349dd94853e6132db6d832ae6a4b951312b6926ad4ee7ca3", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dcdc84334b0e9e2cd439bd2653678a822623f212c71088edf0a4a7d03f1fa225"},
"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"}, "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.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [: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", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [: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", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "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.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
"cbor": {:hex, :cbor, "1.0.2", "9b0af85af291a556e10a0ffd48ba9a21a75e711828fafd3af193d56d95f0907f", [:mix], [], "hexpm", "edbc9b4a16eb93a582437b9b249c340a75af03958e338fb43d8c1be9fc65b864"}, "cbor": {:hex, :cbor, "1.0.2", "9b0af85af291a556e10a0ffd48ba9a21a75e711828fafd3af193d56d95f0907f", [:mix], [], "hexpm", "edbc9b4a16eb93a582437b9b249c340a75af03958e338fb43d8c1be9fc65b864"},
@ -63,16 +63,18 @@
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"}, "nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"},
"oban": {:hex, :oban, "2.21.1", "4b6af7b901ef9baca09e239b5a991ef2fa429cf5a13799bc429a131d610ff692", [: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", "8162a160924cf4a25905fed2a9242e7787d88e320e3b5b0dcf324eb17c51c4e6"}, "oban": {:hex, :oban, "2.21.1", "4b6af7b901ef9baca09e239b5a991ef2fa429cf5a13799bc429a131d610ff692", [: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", "8162a160924cf4a25905fed2a9242e7787d88e320e3b5b0dcf324eb17c51c4e6"},
"oban_met": {:hex, :oban_met, "1.0.6", "2a5500aff496b7ac4b830b0b03b08e920625a051bb6890981fbb53b15f1cbdc0", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "15ea3303de76225878a8e6c25a9d62bd1e2e9dd1c46ac8487d873b9f99e8dcee"}, "oban_met": {:hex, :oban_met, "1.1.0", "60eb84614ff31519c7af4c7c9114bae5ba0f3057645dccae19a9589b95fd70f8", [:mix], [{:oban, "~> 2.21", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "535af16a369f94c1ef31b15f4d5de991b07c105eb8689061b326183c52ba6793"},
"oban_pro": {:hex, :oban_pro, "1.6.13", "35db517685f909b7a88bdc1498f6241fc78c439eec63f2ee56d7e6a565f3617c", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}], "oban", "5738c7884d740baa50565cfc348c7c6daace0c349f365dd5c4663d3fb10f854f"},
"oban_web": {:hex, :oban_web, "2.12.1", "74b2b66a453f0072fa690126404f2bf23788f37aca373d3139ca2d8c708b18ee", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.21", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.1", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "d6ee45d6e8c5ed9fbb6213a6cb22870055c0f52eee6f437281e935cf92c408d7"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, 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.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, 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.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "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_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_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.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [: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", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [: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", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_pubsub_redis": {:hex, :phoenix_pubsub_redis, "3.0.1", "d4d856b1e57a21358e448543e1d091e07e83403dde4383b8be04ed9d2c201cbc", [:mix], [{: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, "~> 0.10.0 or ~> 1.0", [hex: :redix, repo: "hexpm", optional: false]}], "hexpm", "0b36a17ff6e9a56159f8df8933d62b5c1f0695eae995a02e0c86c035ace6a309"}, "phoenix_pubsub_redis": {:hex, :phoenix_pubsub_redis, "3.1.0", "8b95de04fdb5d86b4a0797ed5cd6c5e0cdb58bcf3badc766418f6042029c42aa", [: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", "d5de753365ccf1d1a405ea69accde806d5d4e3716ce0b65ed9b3eb6c3d7378f2"},
"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"}, "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.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_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},

4
vendor/README.md vendored
View file

@ -37,7 +37,7 @@ Advanced Oban features including batching, workflows, dynamic partitioning, and
### Oban Web ### Oban Web
**Version**: 2.11.8 **Version**: 2.12.1
**Source**: https://hex.pm/packages/oban_web **Source**: https://hex.pm/packages/oban_web
**License**: Apache-2.0 **License**: Apache-2.0
@ -60,7 +60,7 @@ end
### Oban Met ### Oban Met
**Version**: 1.0.6 **Version**: 1.1.0
**Source**: https://hex.pm/packages/oban_met **Source**: https://hex.pm/packages/oban_met
**License**: Apache-2.0 **License**: Apache-2.0

BIN
vendor/oban_met/.hex vendored

Binary file not shown.

View file

@ -1,10 +1,19 @@
# Changelog for Oban Met v1.0 # Changelog for Oban Met v1.0
## v1.1.0 — 2026-03-25
### Enhancements
- [Met] Require a minimum of Oban v2.21
Oban v2.21 introduces the `suspended` state, which is now counted by the reporter along with the
other states.
## v1.0.6 — 2026-02-19 ## v1.0.6 — 2026-02-19
### Enhancements ### Enhancements
- Add configurable time unit for timing metrics - [Met] Add configurable time unit for timing metrics
Metrics stored in sketches now support configurable time units via the `:sketch_time_unit` Metrics stored in sketches now support configurable time units via the `:sketch_time_unit`
compile-time option. By default, timing values remain in `:native` units for backward compile-time option. By default, timing values remain in `:native` units for backward
@ -14,7 +23,7 @@
and consolidates timing values into fewer bins by eliminating nanosecond-level measurement and consolidates timing values into fewer bins by eliminating nanosecond-level measurement
noise. noise.
- Share crontab options as a map rather than nested lists - [Cronitor] Share crontab options as a map rather than nested lists
For compatibility with entries shared by python, we convert options to a For compatibility with entries shared by python, we convert options to a
map structure rather than a list of lists. map structure rather than a list of lists.

View file

@ -106,6 +106,16 @@ declares an override to set the limit to 200k:
{Oban.Met, reporter: [estimate_limit: 200_000]} {Oban.Met, reporter: [estimate_limit: 200_000]}
``` ```
### Customizing Check Interval
The reporter periodically counts jobs in each state/queue combination to provide the counts
displayed in Oban Web. By default, counts are checked every 1 second. For systems where real-time
counts aren't critical, or to reduce database load, you can increase the interval:
```elixir
{Oban.Met, reporter: [check_interval: :timer.seconds(5)]}
```
### Explicit Migrations ### Explicit Migrations
Met will create the necessary estimate function automatically when possible. The migration isn't Met will create the necessary estimate function automatically when possible. The migration isn't

View file

@ -4,7 +4,7 @@
<<"https://github.com/oban-bg/oban_met/blob/main/CHANGELOG.md">>}, <<"https://github.com/oban-bg/oban_met/blob/main/CHANGELOG.md">>},
{<<"GitHub">>,<<"https://github.com/oban-bg/oban_met">>}]}. {<<"GitHub">>,<<"https://github.com/oban-bg/oban_met">>}]}.
{<<"name">>,<<"oban_met">>}. {<<"name">>,<<"oban_met">>}.
{<<"version">>,<<"1.0.6">>}. {<<"version">>,<<"1.1.0">>}.
{<<"description">>, {<<"description">>,
<<"A distributed, compacting, multidimensional, telemetry-powered time series datastore">>}. <<"A distributed, compacting, multidimensional, telemetry-powered time series datastore">>}.
{<<"elixir">>,<<"~> 1.15">>}. {<<"elixir">>,<<"~> 1.15">>}.
@ -23,6 +23,6 @@
[[{<<"name">>,<<"oban">>}, [[{<<"name">>,<<"oban">>},
{<<"app">>,<<"oban">>}, {<<"app">>,<<"oban">>},
{<<"optional">>,false}, {<<"optional">>,false},
{<<"requirement">>,<<"~> 2.19">>}, {<<"requirement">>,<<"~> 2.21">>},
{<<"repository">>,<<"hexpm">>}]]}. {<<"repository">>,<<"hexpm">>}]]}.
{<<"build_tools">>,[<<"mix">>]}. {<<"build_tools">>,[<<"mix">>]}.

View file

@ -148,7 +148,6 @@ defmodule Oban.Met.Listener do
# For backward compatibility reasons, the telemetry event's state doesn't match the final job # For backward compatibility reasons, the telemetry event's state doesn't match the final job
# state. Here we translate the event state to the `t:Oban.Job.state`. # state. Here we translate the event state to the `t:Oban.Job.state`.
defp trans_state(:cancelled), do: :cancelled
defp trans_state(:discard), do: :discarded defp trans_state(:discard), do: :discarded
defp trans_state(:exhausted), do: :discarded defp trans_state(:exhausted), do: :discarded
defp trans_state(:failure), do: :retryable defp trans_state(:failure), do: :retryable

View file

@ -26,7 +26,8 @@ defmodule Oban.Met.Reporter do
"discarded" => [], "discarded" => [],
"executing" => [], "executing" => [],
"retryable" => [], "retryable" => [],
"scheduled" => [] "scheduled" => [],
"suspended" => []
} }
defstruct [ defstruct [

View file

@ -2,7 +2,7 @@ defmodule Oban.Met.MixProject do
use Mix.Project use Mix.Project
@source_url "https://github.com/oban-bg/oban_met" @source_url "https://github.com/oban-bg/oban_met"
@version "1.0.6" @version "1.1.0"
def project do def project do
[ [
@ -97,7 +97,7 @@ defmodule Oban.Met.MixProject do
defp deps do defp deps do
[ [
{:oban, "~> 2.19"}, {:oban, "~> 2.21"},
{:ecto_sqlite3, "~> 0.18", only: [:test, :dev]}, {:ecto_sqlite3, "~> 0.18", only: [:test, :dev]},
{:postgrex, "~> 0.20", only: [:test, :dev]}, {:postgrex, "~> 0.20", only: [:test, :dev]},
{:stream_data, "~> 1.1", only: [:test, :dev]}, {:stream_data, "~> 1.1", only: [:test, :dev]},

BIN
vendor/oban_web/.hex vendored

Binary file not shown.

View file

@ -1,349 +1,237 @@
# Changelog for Oban Web v2.11 # Changelog for Oban Web v2.12
All notable changes to `Oban.Web` are documented here. This is a major release that overhauls job details, the queues table, queue details, introduces a
crons page, adds a workflows page, and adds a job creation sidebar.
## 🥂 Web is Free and Open Source > #### Requirements {: .info}
>
> This release requires Oban v2.21+ and the new V14 migration due to important schema changes. For
> Pro users, v1.7+ is also required along with the v1.7.0 migration.
Oban Web is now [fully open source][fos] and free (as in champagne 😉)! From now on, Oban Web will ## 🔀 Workflows Page
be [published to Hex][hex] and available for use in your Oban powered applications. See the
updated, much slimmer, [installation guide][ins] to get started!
Web v2.11 is licensed under Apache 2.0, just like Oban and Elixir itself. Previous versions are There is a new page for viewing, filtering, and generally managing workflows. The table displays
commercially licensed, therefore private, and won't be published to Hex. workflow progress, activity counts, duration, and nested sub-workflows. Workflows can be filtered
by properties like name, workers, or status.
Special thanks to all of our customers that supported the project for the past 5 years and made <video autoplay loop muted playsinline loading="lazy" preload="none" style="width: 100%; border-radius: 12px;">
open source Oban, and now Web, possible 💛. <source src="https://media.oban.pro/web-2-12-workflows-av1.mp4" type="video/mp4">
</video>
[fos]: https://github.com/oban-bg/oban_web Clicking into a workflow brings you to a detail view with an interactive graph showing jobs as
[hex]: https://hex.pm/packages/oban_web stateful nodes with dependencies. The graph supports panning, zooming, directional layout
[ins]: installation.md toggling, and a default tracking mode that follows executing nodes. Sub-workflow nodes can be
expanded inline to reveal their internal jobs, or navigated to for direct management such as
retrying or cancelling.
## 🐬🪶 MySQL and SQLite Workflow viewing remains fast on busy systems or with _large_ workflows thanks to the new
`oban_workflows` aggregate table in Pro v1.7. It's also compatible with Python Pro, though
cancel/retry actions require Elixir.
All official Oban and Oban Pro engines are fully supported. Listing, filtering, ordering, and ## ⏰ Crons Page
searching through jobs works for Postgres, MySQL, and SQLite. That includes the particularly
gnarly issue of dynamically generating and manipulating JSON for filter auto-suggestions! Nested
args queries, such as `args.address.city:Edinburgh` work equally well with each engine.
## 🎛️ Instance Select There's also a new page for viewing and managing cron entries. The table displays all static and
dynamic entries with history sparklines and activity details.
The dashboard will now support switching between Oban instances. A new instance select menu in the <video autoplay loop muted playsinline loading="lazy" preload="none" style="width: 100%; border-radius: 12px;">
header allows switching between running Oban instances at runtime. There's no need to mount <source src="https://media.oban.pro/web-2-12-crons-av1.mp4" type="video/mp4">
additional dashboards in your application's router to handle multiple instances. The most recently </video>
selected instance persists between mounts.
```diff The cron detail view includes natural language expressions like "Daily at 8:00 and 9:00" or
- oban_dashboard "/oban_a", oban_name: Oban.A "Weekdays except Monday", along with cron entry specifics, and recent job history.
- oban_dashboard "/oban_b", oban_name: Oban.B
- oban_dashboard "/oban_c", oban_name: Oban.C
+ oban_dashboard "/oban"
```
This also eliminates the need for additional router configuration, as the dashboard will select For Pro users, `DynamicCron` entires can be created, edited, paused, resumed, or deleted directly
the first running Oban instance it finds (with a preference for the default `Oban`). from a form on the details page.
```diff ## 🔍 Job Details
- oban_dashboard "/oban", oban_name: MyOban
+ oban_dashboard "/oban"
```
## ☯️ Unified Tables The job detail page is rebuilt with a full-width layout and a new timeline component that shows
the job state machine as a branching diagram rather than a linear progression.
The queue and jobs tables are fully rebuilt with shared, reusable components and matching <video autoplay loop muted playsinline loading="lazy" preload="none" style="width: 100%; border-radius: 12px;">
functionality. This makes interacting with jobs clearer while queues gain some much requested <source src="https://media.oban.pro/web-2-12-jobs-details-av1.mp4" type="video/mp4">
functionality: </video>
- Sidebar - a new queue sidebar shows status counts and enables filtering by statuses such as A scoped chart displays execution history for that worker's previous jobs and jobs in an
`paused` or `terminating`. incomplete, non-executing state can now be edited directly.
- Filtering - queues are auto-complete filterable just like jobs, making it possible to find Executing Pro jobs display live diagnostics including process status, reductions, memory, and
queues running on a particular node or narrow down by status. current stacktrace. The diagnostics panel persists after job completion with a "Stale" indicator
showing the data is from when the job was running.
- Shared Sorting - queue sorting now behaves identically to jobs, through a shared dropdown. ## ➡️ Queues Table and Details
- Uniform Navigation - click on any part of the queue row to navigate to details. The queues table is redesigned with a utilization gauge and a history sparkline showing 5-minute
throughput for each queue. The queue sidebar provided minimal value, and it was removed to make
room for the additional data displayed per-row.
- Condensed Rows - simplify the queue page by removing nested row components. Extra queue details <video autoplay loop muted playsinline loading="lazy" preload="none" style="width: 100%; border-radius: 12px;">
are in the sub-queue page. <source src="https://media.oban.pro/web-2-12-queues-av1.mp4" type="video/mp4">
</video>
## 🕯️ Operate on Full Selection 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
Apply bulk actions to all selected jobs, not just those visible on the current page. options and burst mode configuration.
This expands the select functionality to extend beyond the current page and include all filtered
jobs, up to a configurable limit. The limit defaults to 1000 and may be overridden with a [resolver
callback][rsc].
[rsc]: Oban.Web.Resolver.html#c:bulk_action_limit/1
## v2.11.8 - 2026-02-06
### Enhancements
- [Dashboard] Add standalone docker image for simplified Web hosting
A standalone Docker image is now available for situations where you want an Oban dashboard
without mounting Oban.Web within your application.
- [Job] Safely handle missing timestamps in jobs table
Manual table updates may forget to include a timestamp for a particular state, which crashes the
table. This provides an empty fallback for missing timestamps.
## v2.11.7 - 2026-01-22
### Enhancements
- [Router] Add configurable `:logo_path` option for dashboard
Allow users to customize where the Oban logo links to by passing a `:logo_path` option to the
`oban_dashboard/1` router helper. When provided, the logo will link to the specified path
instead of the default jobs page.
- [Query] Help query planner with value rather than subquery
Since the condition uses a result of an inner query, database can't reliably estimate that it's
worth to use an index. It "thinks" that many rows will be returned in result and decides to use
a sequential scan.
However, when a specific value is provided, database can use the collected statistics to come up
with a better plan and use the index.
## v2.12.1 - 2026-03-25
### Bug Fixes ### Bug Fixes
- [Jobs] Escape regex match in search highlighting to prevent exceptions - [Dashboard] Include `priv/timezones.txt` in hex package
Unescaped values would crash the LiveView process rather than gracefully returning no matches. The timezones file wasn't included in the package files list, which broke compilation for
downloaded packages.
- [Dashboard] Refactor inline styles to include CSP nonces ## v2.12.0 - 2026-03-25
Avoid CSP warnings for inline styles used to control the sidebar. While all bug fixes are listed below, the enhancements section only covers a portion of the new
features. For enhancements, a video is worth many thousands of words.
- [Dashboard] Respect custom variant for applying dark mode
Additional configuration is required to manually apply dark mode after upgrading to Tailwind 4.
- [Jobs] Correct detail background color for dark modes
## v2.11.6 - 2025-10-24
### Bug Fixes
- [Dashboard] Extract current prefix for correct asset routing
The prefix was mistakenly hard coded to `/oban`, which broke the dashboard for any non-standard
paths. Now the value is extracted from the live view session.
## v2.11.5 - 2025-10-23
### Enhancements ### Enhancements
- [Dashboard] Extract CSS and JS assets into dedicated plug module - [Dashboard] Preserve refresh changes between page changes
The changes move CSS/JS assets from layouts into a dedicated plug module, enabling cached and The refresh reverted to the original value between changes because there wasn't a new liveview
immutable asset serving. connection. Now the stored refresh value is synced on change, and reloaded when the component
mounts.
- [Dashboard] Upgrade to Tailwind v4 with updated styles - [Dahboard] Add a 30s option for refreshing
- [Dashboard] Add sidebar resizing with local persistence It's a simple addition that makes watching the cron page a bit more sensible.
The sidebar can now be resized within a small range of sizes to compensate for long queue names - [Dashboard] Serve dynamically loaded mask based svg icons
or larger screens. The adjusted size is persisted as a setting within local storage, preserving
it across reloads.
- [Dashboard] Overhaul navigation for state retention between pages Rather than manually defining SVG icons inline, SVG files are tracked and dynamically loaded
from a compiled assets module.
Page navigation uses patch rather than navigate to prevent re-mounting the dashboard. This was - [Dashboard] Serve font as static asset instead of embedding
essential to retain sidebar changes during page transitions.
- [Telemetry] Add `queues` to metadata of `*_queues` telemetry events Extract font out of the `app.css` and serve it as a stand-alone asset for better caching.
Telemetry events for `pause_queues`, `resume_queues`, and `stop_queues` now include the - [Dashboard] Add help button to primary toolbar
selected queues.
The keyboard shortcuts modal was only accessible via the ? key with no visual indication it
existed. Added a help dropdown to the toolbar that links to documentation and opens the
shortcuts modal.
- [Jobs] Support suspended state in sidebar and details
The latest Oban version adds a proper `suspended` state, so there's no more on_hold
psuedo-state.
- [Jobs] Jobs rescued by any lifeline are detectable
Change the language for orphans to correctly indicate that any lifeline may have rescued them.
- [Jobs] Redesign timeline as state machine visualization
Replace the linear horizontal timeline with a branching layout that accurately represents the
job state machine. Entry states (scheduled/retryable) flow into available, then executing, which
branches to terminal states (completed/cancelled/discarded).
- [Jobs] Add diagnostics for executing jobs
Actively executing `Oban.Pro.Worker` jobs now display diagnostics including process status,
reductions, memory, and the current stacktrace.
- [Jobs] Add job editing to job detail page
Job's in an incomplete and non-executing state can have their attributes edited. Internally,
`Oban.update_job/3` is used to perform the update, so standard validations still apply.
- [Jobs] Refine layout and error display for job details
Restructure information for job details to emphasize what's important (args, the most recent
error), while providing control over which information is displayed.
- [Jobs] Show history chart component for job detail
The new component uses exact historic information for the current job rather than the aggregate
metrics used for the primary job chart.
- [Jobs] Add "New Job" drawer for creating jobs
Jobs can now be created directly from the Jobs page using a slide-out drawer. The form includes
fields for worker, args, queue, priority, max attempts, scheduled time, and tags. After
creation, the user is navigated to the new job's detail page.
- [Crons] Use name provided by crontab for entry job history
The `cron_name` calculated for Elixir entries isn't compatible with those generated by Python.
The `oban-py` metrics now include a `name` option that is used to correctly match entries up
with historic jobs.
- [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"
- Weekday/weekend recognition
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
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
component.
- [Queues] Add history sparkline graph to queues table
Display a 5-minute throughput history for each queue using a sparkline
visualization with 5-second rollups (60 data points). Hovering over bars
shows the job count and timestamp for that interval.
- [Queues] Refine queue detail forms and layout
Expand the queue form partitioning controls with "meta" options and burst mode. Also changes to
standard form inputs for numbers and select boxes within the edit form to simplify event
handling.
- [Queues] Redesign queue details with actions and history
Add status badges for paused, partial, and terminating states. Include pause/resume, stop, and
edit buttons in the header for quick access. Display queue execution history in a chart
alongside stats.
- [Pages] Add empty states for workflows, crons, and queues
Each page now shows a helpful message with an icon and documentation link when there's nothing
to display. The queues page distinguishes between having no queues configured versus filters
hiding all results.
- [Pages] Improve dark mode color consistency and contrast
Standardize border colors across form inputs and controls, align form input backgrounds, and
increase contrast for disabled elements.
### Bug Fixes ### Bug Fixes
- [Router] Ensure the resolver module is loaded - [Dahboard] Fix instance switching when resolver returns a list
Optional resolver modules are now loaded before checking whether they export a function. This Ensure the instance name and allowed instances are strings before comparison
ensures functions are correctly identified, as they aren't automatically loaded locally by
Elixir (unless eager module loading is turned on).
- [Jobs] Display correct completed_at timestamp in details - [Dashboard] Poll registry instead of blocking on telemetry
Completed jobs would show a relative time value that matched how long the job took rather than Replace telemetry-based init that could block for 15 seconds waiting for an init event that may
when it completed. have already fired. Now polls the Oban registry, avoiding the race condition that caused slow
websocket reconnections.
- [Dashboard] Use history navigation for detail back buttons - [Dashboard] Cache sidebar counts to prevent flickering
Clicking the back arrow in job and queue detail pages removed any applied params, as it dropped When the metric reporter's `check_interval` exceeds the 2s lookback window, counts briefly show
any non-id params on navigation. The new approach uses a history hook that leverages native as zero between broadcasts. Cache previous non-empty counts in a centralized Metrics module and
window history instead. return them when `Met.latest/3` returns an empty map.
## v2.11.4 - 2025-07-31 - [Dashboard] Automatically update theme when OS theme changes
### Enhancements Listen for prefers-color-scheme media query changes so the theme updates in real-time when the
browser or OS switches between light and dark mode.
- [Connectivity] Consider metric checks for disconnected status. - [Standalone] Use the Postgres notifier for standalone instance
The connectivity status is now determined by `Met` output as well as pubsub connectivity. The PG notifier can't (easily) connect to an external cluster for notifications. Connection is
This should make it easier to identify metric issues on solo nodes, e.g. in dev or a possible through the Postgres notifier.
staging environment.
### Bug Fixes
- [Dashboard] Read phoenix js assets at compile time.
Stop bundling phoenix and liveview assets. Instead, read them at compile time and concatenate
with app js.
- [Search] Trim strings when splitting to parse integers.
This prevents "not a textual representation of an integer" errors when splitting on a comma
with an empty string.
- [Search] Move all regexes out of module attributes.
Regexes aren't allowed in module attributes as of OTP 28. This moves them inline rather than
hoisted at the top of the module.
- [Sidebar] Fix column header mismatch in sidebar.
The headers and values in the sidebar were misaligned and showed the wrong values.
## v2.11.3 - 2025-04-21
### Bug Fixes
- [Installer] Prevent compilation errors when igniter isn't installed.
## v2.11.2 - 2025-04-21
### Enhancements
- [Installer] Add igniter powered `oban_web` installer.
It's now possible to install oban_web with a single igniter command:
```bash
mix igniter.install oban_web
```
Or install `oban` and `oban_web` at the same time:
```bash
mix igniter.install oban,oban_web
```
- [Resolver] Pattern match on `arg` rather than checking for `decorated` annotation.
Matching on term encoded `arg` is more accurate than checking for decorated metadata. This makes
the default `format_job_args` compatible with workflow cascade jobs that don't have any `arg`
set.
- [Page] Upgrade bundled assets to use the Phoenix LiveView JavaScript version to v1.10
- [Chart] Replace inline styles with tailwind classes to avoid inline style CSP warnings.
## v2.11.1 — 2025-02-06
### Enhancements
- [Layout] Display Oban.Met version in layout footer
The Met version is highly relevant to how Web behaves. This also refactors the version display
for reuse with consistent conditionals.
- [Layout] Only show Pro version number when available.
The version footer shows that Pro isn't available rather than showing a `v` followed by a blank
space.
- [Jobs] Auto-complete worker priorities from 0 to 9
Priority completion only matched values from 0..3, but the full range is 0..9.
### Bug Fixes
- [Jobs] Preserve quotes in `args` and `meta` searches.
Parsing would strip quotes from args and meta queries. This prevented quoted numeric vaalues to
be treated as integers. Now quotes are preserved for `args` and `meta`, just as they are for
other qualifiers.
- [Queue Details] Eliminate duplicate id warnings for inputs in queue details.
The latest live_view alerts when nodes in a view have conflicting ids, which caught a number of
instances on the queue details page.
- [Queues] Fix duplicate ids for nodes and queues in sidebar.
- [Search] Correct assigns typo in search component handler.
The key is `assigns`, not `asigns`.
- [Resolver] Fix resolver access typespec.
Access options must be a keyword list with boolean values, not just a list of option atoms.
## v2.11.0 — 2025-01-16
### Enhancements
- [Dashboard] Load using non-default Oban instance without any config.
The dashboard now loads the first running non-default Oban instance, even without anything
configured.
- [Jobs] Decode decorated `arg` when formatting job args.
The decorated `arg` are term encoded and inscrutiable in the standard display. Now the value is
decoded and display as the original value.
- [Jobs] Prevent decoding executable functions when displaying `recorded` output.
By default, recorded content uses the `:safe` flag, which now prevents both atom creation and
executable content. This is an extra security precaution to prevent any possible exploits
through Web.
- [Queues] Add sidebar similar to the jobs table, including filters to make bulk operations on
queues possible.
- [Queues] Add search bar with filtering functionality to query queues.
- [Queues] Add multi-select mode to allow operating on multiple queues at once.
- [Queues] Expose a "Stop Queues" operation along with corresponding access controls to prevent
unintended shutdown.
- [Resolver] Add `resolve_instances/1` callback to restrict selectable Oban instances.
Not all instances should be available to all users. This adds the `resolve_instances/1` callback
to allow scoping instances by user:
```elixir
def resolve_instances(%{admin?: true}), do: :all
def resolve_instances(_user), do: [Oban]
```
- [Resolver] Add `bulk_action_limit/1` to restrict the number of jobs operated on at once.
Bulk operations now effect all filtered jobs, not just those visible on the current page. As
there may be millions of jobs that match a filter, some limiting factor is necessary. The
default of 1000 may be overridden:
```elixir
def bulk_action_limit(:completed), do: 10_000
def bulk_action_limit(_state), do: :infinity
```
### Bug Fixes
- [Page] Address deprecations from recent upgrades to Elixir 1.17+, Phoenix 1.5+, and Phoenix
LiveView 1.0+
- [Jobs] Always treat args wrapped in quotes as a string when filtering.
Manually wrapping values in quotes enforces searching by string, rather than as a parsed
integer. As values are cast to JSON the type matters, and `123 != "123"`.

View file

@ -108,6 +108,30 @@ For development, a single file server that generates a wide variety of fake jobs
iex -S mix dev iex -S mix dev
``` ```
### Python Development Workers
A Python development app is available that runs alongside the Elixir dev server, sharing the same
database. Python workers use separate queues (inference, scraping, etl, webhooks, notifications,
transcoding, maintenance) and are tagged with "python" for easy filtering in the dashboard.
First, install the Python dependencies (requires [uv](https://docs.astral.sh/uv/)):
```bash
mix py.install
```
Then, with the Elixir dev server running in one terminal, start the Python workers in another:
```bash
mix py.dev
```
Alternatively, run directly from the py directory:
```bash
cd py && uv run py-dev
```
### Testing with Oban Pro ### Testing with Oban Pro
Oban Pro is an optional dependency for development and testing. The test suite will automatically Oban Pro is an optional dependency for development and testing. The test suite will automatically

View file

@ -4,16 +4,20 @@
<<"https://github.com/oban-bg/oban_web/blob/main/CHANGELOG.md">>}, <<"https://github.com/oban-bg/oban_web/blob/main/CHANGELOG.md">>},
{<<"GitHub">>,<<"https://github.com/oban-bg/oban_web">>}]}. {<<"GitHub">>,<<"https://github.com/oban-bg/oban_web">>}]}.
{<<"name">>,<<"oban_web">>}. {<<"name">>,<<"oban_web">>}.
{<<"version">>,<<"2.11.8">>}. {<<"version">>,<<"2.12.1">>}.
{<<"description">>,<<"Dashboard for the Oban job processing framework">>}. {<<"description">>,<<"Dashboard for the Oban job orchestration framework">>}.
{<<"elixir">>,<<"~> 1.15">>}. {<<"elixir">>,<<"~> 1.15">>}.
{<<"files">>, {<<"files">>,
[<<"lib">>,<<"lib/mix">>,<<"lib/mix/tasks">>, [<<"lib">>,<<"lib/mix">>,<<"lib/mix/tasks">>,
<<"lib/mix/tasks/oban_web.install.ex">>,<<"lib/oban">>,<<"lib/oban/web">>, <<"lib/mix/tasks/oban_web.install.ex">>,<<"lib/oban">>,<<"lib/oban/web">>,
<<"lib/oban/web/dashboard_live.ex">>,<<"lib/oban/web/page.ex">>, <<"lib/oban/web/cron.ex">>,<<"lib/oban/web/dashboard_live.ex">>,
<<"lib/oban/web/telemetry.ex">>,<<"lib/oban/web/cache.ex">>, <<"lib/oban/web/page.ex">>,<<"lib/oban/web/telemetry.ex">>,
<<"lib/oban/web/plugins">>,<<"lib/oban/web/plugins/stats.ex">>, <<"lib/oban/web/cache.ex">>,<<"lib/oban/web/plugins">>,
<<"lib/oban/web/queue.ex">>,<<"lib/oban/web/queries">>, <<"lib/oban/web/plugins/stats.ex">>,<<"lib/oban/web/queue.ex">>,
<<"lib/oban/web/metrics.ex">>,<<"lib/oban/web/queries">>,
<<"lib/oban/web/queries/query_helpers.ex">>,
<<"lib/oban/web/queries/workflow_query.ex">>,
<<"lib/oban/web/queries/cron_query.ex">>,
<<"lib/oban/web/queries/job_query.ex">>, <<"lib/oban/web/queries/job_query.ex">>,
<<"lib/oban/web/queries/queue_query.ex">>,<<"lib/oban/web/router.ex">>, <<"lib/oban/web/queries/queue_query.ex">>,<<"lib/oban/web/router.ex">>,
<<"lib/oban/web/components">>,<<"lib/oban/web/components/sort.ex">>, <<"lib/oban/web/components">>,<<"lib/oban/web/components/sort.ex">>,
@ -24,30 +28,115 @@
<<"lib/oban/web/components/layouts">>, <<"lib/oban/web/components/layouts">>,
<<"lib/oban/web/components/layouts/live.html.heex">>, <<"lib/oban/web/components/layouts/live.html.heex">>,
<<"lib/oban/web/components/layouts/root.html.heex">>, <<"lib/oban/web/components/layouts/root.html.heex">>,
<<"lib/oban/web/components/form_components.ex">>,
<<"lib/oban/web/workflow.ex">>,<<"lib/oban/web/timezones.ex">>,
<<"lib/oban/web/live">>,<<"lib/oban/web/live/search_component.ex">>, <<"lib/oban/web/live">>,<<"lib/oban/web/live/search_component.ex">>,
<<"lib/oban/web/live/workflows">>,
<<"lib/oban/web/live/workflows/table_component.ex">>,
<<"lib/oban/web/live/workflows/detail_component.ex">>,
<<"lib/oban/web/live/shortcuts_component.ex">>, <<"lib/oban/web/live/shortcuts_component.ex">>,
<<"lib/oban/web/live/theme_component.ex">>,<<"lib/oban/web/live/queues">>, <<"lib/oban/web/live/theme_component.ex">>,<<"lib/oban/web/live/queues">>,
<<"lib/oban/web/live/queues/table_component.ex">>, <<"lib/oban/web/live/queues/table_component.ex">>,
<<"lib/oban/web/live/queues/sidebar_component.ex">>,
<<"lib/oban/web/live/queues/detail_component.ex">>, <<"lib/oban/web/live/queues/detail_component.ex">>,
<<"lib/oban/web/live/queues/detail_instance_component.ex">>, <<"lib/oban/web/live/queues/detail_instance_component.ex">>,
<<"lib/oban/web/live/jobs">>, <<"lib/oban/web/live/help_component.ex">>,<<"lib/oban/web/live/crons">>,
<<"lib/oban/web/live/crons/table_component.ex">>,
<<"lib/oban/web/live/crons/new_component.ex">>,
<<"lib/oban/web/live/crons/detail_component.ex">>,
<<"lib/oban/web/live/crons/helpers.ex">>,<<"lib/oban/web/live/jobs">>,
<<"lib/oban/web/live/jobs/table_component.ex">>, <<"lib/oban/web/live/jobs/table_component.ex">>,
<<"lib/oban/web/live/jobs/sidebar_component.ex">>, <<"lib/oban/web/live/jobs/sidebar_component.ex">>,
<<"lib/oban/web/live/jobs/new_component.ex">>,
<<"lib/oban/web/live/jobs/detail_component.ex">>, <<"lib/oban/web/live/jobs/detail_component.ex">>,
<<"lib/oban/web/live/jobs/history_chart_component.ex">>,
<<"lib/oban/web/live/jobs/timeline_component.ex">>, <<"lib/oban/web/live/jobs/timeline_component.ex">>,
<<"lib/oban/web/live/jobs/chart_component.ex">>, <<"lib/oban/web/live/jobs/chart_component.ex">>,
<<"lib/oban/web/live/connectivity_component.ex">>, <<"lib/oban/web/live/connectivity_component.ex">>,
<<"lib/oban/web/live/instances_component.ex">>, <<"lib/oban/web/live/instances_component.ex">>,
<<"lib/oban/web/live/refresh_component.ex">>,<<"lib/oban/web/helpers.ex">>, <<"lib/oban/web/live/refresh_component.ex">>,<<"lib/oban/web/helpers.ex">>,
<<"lib/oban/web/application.ex">>,<<"lib/oban/web/timing.ex">>, <<"lib/oban/web/colors.ex">>,<<"lib/oban/web/cron_expr.ex">>,
<<"lib/oban/web/pages">>,<<"lib/oban/web/pages/jobs_page.ex">>, <<"lib/oban/web/application.ex">>,<<"lib/oban/web/utils.ex">>,
<<"lib/oban/web/pages/queues_page.ex">>,<<"lib/oban/web/resolver.ex">>, <<"lib/oban/web/timing.ex">>,<<"lib/oban/web/pages">>,
<<"lib/oban/web/pages/jobs_page.ex">>,
<<"lib/oban/web/pages/crons_page.ex">>,
<<"lib/oban/web/pages/queues_page.ex">>,
<<"lib/oban/web/pages/workflows_page.ex">>,<<"lib/oban/web/resolver.ex">>,
<<"lib/oban/web/authentication.ex">>,<<"lib/oban/web/helpers">>, <<"lib/oban/web/authentication.ex">>,<<"lib/oban/web/helpers">>,
<<"lib/oban/web/helpers/queue_helper.ex">>,<<"lib/oban/web/assets.ex">>, <<"lib/oban/web/helpers/queue_helper.ex">>,<<"lib/oban/web/assets.ex">>,
<<"lib/oban/web/search.ex">>,<<"lib/oban/web/exceptions.ex">>, <<"lib/oban/web/search.ex">>,<<"lib/oban/web/exceptions.ex">>,
<<"lib/oban/web.ex">>,<<"priv/static">>,<<"priv/static/app.css">>, <<"lib/oban/web.ex">>,<<"priv/static">>,<<"priv/static/app.css">>,
<<"priv/static/app.js">>,<<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>, <<"priv/static/icons">>,<<"priv/static/icons/solid">>,
<<"priv/static/icons/solid/pause-circle.svg">>,
<<"priv/static/icons/solid/play-circle.svg">>,
<<"priv/static/icons/solid/crossbones-circle.svg">>,
<<"priv/static/icons/special">>,<<"priv/static/icons/special/spinner.svg">>,
<<"priv/static/icons/outline">>,
<<"priv/static/icons/outline/arrow-top-right-on-square.svg">>,
<<"priv/static/icons/outline/queue-list.svg">>,
<<"priv/static/icons/outline/pause-circle.svg">>,
<<"priv/static/icons/outline/computer-desktop.svg">>,
<<"priv/static/icons/outline/cog.svg">>,
<<"priv/static/icons/outline/play-circle.svg">>,
<<"priv/static/icons/outline/x-circle.svg">>,
<<"priv/static/icons/outline/square-2x2.svg">>,
<<"priv/static/icons/outline/no-symbol.svg">>,
<<"priv/static/icons/outline/chevron-down.svg">>,
<<"priv/static/icons/outline/adjustments-horizontal.svg">>,
<<"priv/static/icons/outline/power.svg">>,
<<"priv/static/icons/outline/cog-8-tooth.svg">>,
<<"priv/static/icons/outline/sparkles.svg">>,
<<"priv/static/icons/outline/chevron-up.svg">>,
<<"priv/static/icons/outline/table-cells.svg">>,
<<"priv/static/icons/outline/arrow-path.svg">>,
<<"priv/static/icons/outline/ellipsis-horizontal-circle.svg">>,
<<"priv/static/icons/outline/chevron-right.svg">>,
<<"priv/static/icons/outline/clipboard.svg">>,
<<"priv/static/icons/outline/link.svg">>,
<<"priv/static/icons/outline/bars-arrow-down.svg">>,
<<"priv/static/icons/outline/arrow-right.svg">>,
<<"priv/static/icons/outline/pencil-square.svg">>,
<<"priv/static/icons/outline/bolt-circle.svg">>,
<<"priv/static/icons/outline/arrow-turn-down-right.svg">>,
<<"priv/static/icons/outline/arrow-path-rounded.svg">>,
<<"priv/static/icons/outline/indeterminate.svg">>,
<<"priv/static/icons/outline/check.svg">>,
<<"priv/static/icons/outline/percent-square.svg">>,
<<"priv/static/icons/outline/rectangle-group.svg">>,
<<"priv/static/icons/outline/x-mark.svg">>,
<<"priv/static/icons/outline/info-circle.svg">>,
<<"priv/static/icons/outline/arrow-right-circle.svg">>,
<<"priv/static/icons/outline/check-circle.svg">>,
<<"priv/static/icons/outline/camera.svg">>,
<<"priv/static/icons/outline/life-buoy.svg">>,
<<"priv/static/icons/outline/view-columns.svg">>,
<<"priv/static/icons/outline/bolt-slash.svg">>,
<<"priv/static/icons/outline/exclamation-circle.svg">>,
<<"priv/static/icons/outline/play-pause-circle.svg">>,
<<"priv/static/icons/outline/trash.svg">>,
<<"priv/static/icons/outline/hashtag.svg">>,
<<"priv/static/icons/outline/sun.svg">>,
<<"priv/static/icons/outline/bars-arrow-up.svg">>,
<<"priv/static/icons/outline/plus-circle.svg">>,
<<"priv/static/icons/outline/user-circle.svg">>,
<<"priv/static/icons/outline/user-group.svg">>,
<<"priv/static/icons/outline/clock.svg">>,
<<"priv/static/icons/outline/command-line.svg">>,
<<"priv/static/icons/outline/adjustments-vertical.svg">>,
<<"priv/static/icons/outline/question-mark-circle.svg">>,
<<"priv/static/icons/outline/map-pin.svg">>,
<<"priv/static/icons/outline/globe.svg">>,
<<"priv/static/icons/outline/arrow-left.svg">>,
<<"priv/static/icons/outline/magnifying-glass.svg">>,
<<"priv/static/icons/outline/minus-circle.svg">>,
<<"priv/static/icons/outline/lock-closed.svg">>,
<<"priv/static/icons/outline/arrow-trending-down.svg">>,
<<"priv/static/icons/outline/chevron-left.svg">>,
<<"priv/static/icons/outline/moon.svg">>,
<<"priv/static/icons/outline/chart-bar-square.svg">>,
<<"priv/static/icons/outline/square-stack.svg">>,
<<"priv/static/icons/outline/calendar-days.svg">>,<<"priv/static/fonts">>,
<<"priv/static/fonts/Inter.woff2">>,<<"priv/static/app.js">>,
<<"priv/timezones.txt">>,<<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>,
<<"CHANGELOG.md">>,<<"LICENSE.txt">>]}. <<"CHANGELOG.md">>,<<"LICENSE.txt">>]}.
{<<"app">>,<<"oban_web">>}. {<<"app">>,<<"oban_web">>}.
{<<"licenses">>,[<<"Apache-2.0">>]}. {<<"licenses">>,[<<"Apache-2.0">>]}.
@ -80,11 +169,11 @@
[{<<"name">>,<<"oban">>}, [{<<"name">>,<<"oban">>},
{<<"app">>,<<"oban">>}, {<<"app">>,<<"oban">>},
{<<"optional">>,false}, {<<"optional">>,false},
{<<"requirement">>,<<"~> 2.19">>}, {<<"requirement">>,<<"~> 2.21">>},
{<<"repository">>,<<"hexpm">>}], {<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"oban_met">>}, [{<<"name">>,<<"oban_met">>},
{<<"app">>,<<"oban_met">>}, {<<"app">>,<<"oban_met">>},
{<<"optional">>,false}, {<<"optional">>,false},
{<<"requirement">>,<<"~> 1.0">>}, {<<"requirement">>,<<"~> 1.1">>},
{<<"repository">>,<<"hexpm">>}]]}. {<<"repository">>,<<"hexpm">>}]]}.
{<<"build_tools">>,[<<"mix">>]}. {<<"build_tools">>,[<<"mix">>]}.

View file

@ -5,6 +5,36 @@ defmodule Oban.Web.Assets do
import Plug.Conn import Plug.Conn
@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")
@font File.read!(font_path)
# CSS
@external_resource css_path = Path.join(@static_path, "app.css")
@css File.read!(css_path)
# JS
phoenix_js_paths = phoenix_js_paths =
for app <- ~w(phoenix phoenix_html phoenix_live_view)a do for app <- ~w(phoenix phoenix_html phoenix_live_view)a do
path = Application.app_dir(app, ["priv", "static", "#{app}.js"]) path = Application.app_dir(app, ["priv", "static", "#{app}.js"])
@ -12,13 +42,8 @@ defmodule Oban.Web.Assets do
path path
end end
@static_path Application.app_dir(:oban_web, ["priv", "static"])
@external_resource css_path = Path.join(@static_path, "app.css")
@external_resource js_path = Path.join(@static_path, "app.js") @external_resource js_path = Path.join(@static_path, "app.js")
@css File.read!(css_path)
@js """ @js """
#{for path <- phoenix_js_paths, do: path |> File.read!() |> String.replace("//# sourceMappingURL=", "// ")} #{for path <- phoenix_js_paths, do: path |> File.read!() |> String.replace("//# sourceMappingURL=", "// ")}
#{File.read!(js_path)} #{File.read!(js_path)}
@ -28,9 +53,34 @@ defmodule Oban.Web.Assets do
def init(asset), do: asset def init(asset), do: asset
@impl Plug @impl Plug
def call(conn, asset) do def call(conn, :css) do
{contents, content_type} = contents_and_type(asset) serve_asset(conn, @css, "text/css")
end
def call(conn, :js) do
serve_asset(conn, @js, "text/javascript")
end
def call(conn, :font) 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 conn
|> put_resp_header("content-type", content_type) |> put_resp_header("content-type", content_type)
|> put_resp_header("cache-control", "public, max-age=31536000, immutable") |> put_resp_header("cache-control", "public, max-age=31536000, immutable")
@ -39,9 +89,6 @@ defmodule Oban.Web.Assets do
|> halt() |> halt()
end end
defp contents_and_type(:css), do: {@css, "text/css"}
defp contents_and_type(:js), do: {@js, "text/javascript"}
for {key, val} <- [css: @css, js: @js] do for {key, val} <- [css: @css, js: @js] do
md5 = Base.encode16(:crypto.hash(:md5, val), case: :lower) md5 = Base.encode16(:crypto.hash(:md5, val), case: :lower)

93
vendor/oban_web/lib/oban/web/colors.ex vendored Normal file
View file

@ -0,0 +1,93 @@
defmodule Oban.Web.Colors do
@moduledoc false
# Hex colors for SVGs and inline styles (must match assets/js/lib/colors.js)
@hex %{
blue: "#60a5fa",
cyan: "#22d3ee",
emerald: "#34d399",
gray: "#9ca3af",
indigo: "#818cf8",
rose: "#fb7185",
violet: "#a78bfa",
yellow: "#facc15"
}
# State to color name mapping
@state_color_names %{
"suspended" => :gray,
"scheduled" => :indigo,
"available" => :blue,
"retryable" => :yellow,
"executing" => :emerald,
"completed" => :cyan,
"cancelled" => :violet,
"discarded" => :rose
}
# Tailwind classes per state for templates
@state_classes %{
"suspended" => {"border-gray-400", "bg-gray-400/10", "text-gray-600 dark:text-gray-400"},
"scheduled" =>
{"border-indigo-400", "bg-indigo-400/10", "text-indigo-600 dark:text-indigo-400"},
"available" => {"border-blue-400", "bg-blue-400/10", "text-blue-600 dark:text-blue-400"},
"retryable" =>
{"border-yellow-400", "bg-yellow-400/10", "text-yellow-600 dark:text-yellow-400"},
"executing" =>
{"border-emerald-400", "bg-emerald-400/10", "text-emerald-600 dark:text-emerald-400"},
"completed" => {"border-cyan-400", "bg-cyan-400/10", "text-cyan-600 dark:text-cyan-400"},
"cancelled" =>
{"border-violet-400", "bg-violet-400/10", "text-violet-600 dark:text-violet-400"},
"discarded" => {"border-rose-400", "bg-rose-400/10", "text-rose-600 dark:text-rose-400"}
}
@inactive_classes {
"border-gray-300 dark:border-gray-600",
"bg-gray-100 dark:bg-gray-800",
"text-gray-400 dark:text-gray-500"
}
@doc """
Returns the hex color for a state (for SVGs and inline styles).
"""
def state_hex(state) when is_binary(state) do
color_name = Map.get(@state_color_names, state, :gray)
Map.fetch!(@hex, color_name)
end
def state_hex(_state), do: Map.fetch!(@hex, :gray)
@doc """
Returns Tailwind classes for a state: {border, background, text}.
"""
def state_classes(state) when is_binary(state) do
Map.get(@state_classes, state, @inactive_classes)
end
def state_classes(_state), do: @inactive_classes
@doc """
Returns Tailwind classes for inactive state.
"""
def inactive_classes, do: @inactive_classes
@doc """
Returns the background class for a state (e.g., "bg-cyan-400").
"""
def state_bg_class(state) when is_binary(state) do
color_name = Map.get(@state_color_names, state, :gray)
"bg-#{color_name}-400"
end
def state_bg_class(_state), do: "bg-gray-400"
@doc """
Returns the text class for a state (e.g., "text-cyan-400").
"""
def state_text_class(state) when is_binary(state) do
color_name = Map.get(@state_color_names, state, :gray)
"text-#{color_name}-400"
end
def state_text_class(_state), do: "text-gray-400"
end

View file

@ -41,65 +41,6 @@ defmodule Oban.Web.Components.Core do
""" """
end end
@doc """
A numerical input with increment and decrement buttons.
"""
def number_input(assigns) do
~H"""
<div>
<%= if @label do %>
<label
for={@name}
class={"block font-medium text-sm mb-2 #{if @disabled, do: "text-gray-600 dark:text-gray-400", else: "opacity-50"}"}
>
{@label}
</label>
<% end %>
<div class="flex">
<input
autocomplete="off"
class="w-1/2 flex-1 min-w-0 block font-mono text-sm shadow-sm border-gray-300 dark:border-gray-500 bg-gray-50 dark:bg-gray-800 disabled:opacity-50 rounded-l-md focus:ring-blue-400 focus:border-blue-400"
disabled={@disabled}
inputmode="numeric"
name={@name}
pattern="[1-9][0-9]*"
placeholder="Off"
type="text"
value={@value}
/>
<div class="w-9">
<button
rel="inc"
class="block -ml-px px-3 py-1 bg-gray-300 dark:bg-gray-500 rounded-tr-md hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
disabled={@disabled}
type="button"
phx-click="increment"
phx-target={@myself}
phx-value-field={@name}
>
<Icons.chevron_up class="w-3 h-3 text-gray-600 dark:text-gray-200" />
</button>
<button
rel="dec"
class="block -ml-px px-3 py-1 bg-gray-300 dark:bg-gray-500 rounded-br-md hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
disabled={@disabled}
tabindex="-1"
type="button"
phx-click="decrement"
phx-target={@myself}
phx-value-field={@name}
>
<Icons.chevron_down class="w-3 h-3 text-gray-600 dark:text-gray-200" />
</button>
</div>
</div>
</div>
"""
end
slot :inner_block, required: true slot :inner_block, required: true
attr :name, :string, required: true attr :name, :string, required: true
attr :options, :list, required: true attr :options, :list, required: true
@ -114,7 +55,7 @@ defmodule Oban.Web.Components.Core do
<button <button
aria-expanded="true" aria-expanded="true"
aria-haspopup="listbox" aria-haspopup="listbox"
class="text-gray-500 dark:text-gray-400 disabled:text-gray-400 disabled:dark:text-gray-600 focus:outline-none hover:text-gray-800 dark:hover:text-gray-200 hidden md:block" class="cursor-pointer text-gray-500 dark:text-gray-400 disabled:text-gray-400 disabled:dark:text-gray-500 focus:outline-none hover:text-gray-800 dark:hover:text-gray-200 hidden md:block"
data-title={@title} data-title={@title}
disabled={@disabled} disabled={@disabled}
id={"#{@name}-menu-toggle"} id={"#{@name}-menu-toggle"}
@ -200,8 +141,8 @@ defmodule Oban.Web.Components.Core do
phx-value-id={@value} phx-value-id={@value}
rel="check" rel="check"
> >
<div class={["w-4 h-4 rounded border", @style]}> <div class={["w-4 h-4 flex items-center justify-center rounded border", @style]}>
<Icons.check class="w-3.5 h-3.5 text-white dark:text-gray-900" /> <Icons.check class="w-3 h-3 text-white dark:text-gray-900" />
</div> </div>
</button> </button>
""" """
@ -231,14 +172,253 @@ defmodule Oban.Web.Components.Core do
phx-target={@myself} phx-target={@myself}
type="button" type="button"
> >
<div class={["w-4 h-4 rounded border", @style]}> <div class={["w-4 h-4 flex items-center justify-center rounded border", @style]}>
<%= if @checked == :some do %> <%= if @checked == :some do %>
<Icons.indeterminate class="w-3.5 h-3.5 text-white dark:text-gray-900" /> <Icons.indeterminate class="w-3 h-3 text-white dark:text-gray-900" />
<% else %> <% else %>
<Icons.check class="w-3.5 h-3.5 text-white dark:text-gray-900" /> <Icons.check class="w-3 h-3 text-white dark:text-gray-900" />
<% end %> <% end %>
</div> </div>
</button> </button>
""" """
end end
@doc """
A status badge with icon that expands to show label on hover.
"""
attr :icon, :string, required: true
attr :id, :string, default: nil
attr :label, :string, required: true
def status_badge(assigns) do
~H"""
<div id={@id} class="group flex items-center cursor-default select-none">
<span class="inline-flex items-center justify-center h-9 pl-2.5 pr-2.5 group-hover:pr-4 rounded-full text-sm font-medium bg-violet-100 text-violet-700 dark:bg-violet-700/70 dark:text-violet-200 transition-all duration-200">
<.badge_icon name={@icon} />
<span class="max-w-0 overflow-hidden group-hover:max-w-24 group-hover:ml-1.5 transition-all duration-200 whitespace-nowrap">
{@label}
</span>
</span>
</div>
"""
end
defp badge_icon(%{name: "camera"} = assigns), do: ~H[<Icons.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" />]
defp badge_icon(%{name: "lock_closed"} = assigns),
do: ~H[<Icons.lock_closed class="h-4 w-4 shrink-0" />]
defp badge_icon(%{name: "sparkles"} = assigns),
do: ~H[<Icons.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" />]
defp badge_icon(%{name: "square_2x2"} = assigns),
do: ~H[<Icons.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" />]
defp badge_icon(%{name: "user_group"} = assigns),
do: ~H[<Icons.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" />]
defp badge_icon(%{name: "power"} = assigns),
do: ~H[<Icons.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" />]
defp badge_icon(%{name: "play_pause_circle"} = assigns),
do: ~H[<Icons.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.
"""
attr :id, :string, required: true
attr :icon, :string, required: true
attr :label, :string, required: true
attr :color, :string, required: true
attr :disabled, :boolean, default: false
attr :confirm, :string, default: nil
attr :tooltip, :string, default: nil
attr :rest, :global
def icon_button(assigns) do
color_classes =
case {assigns.color, assigns.disabled} do
{_, true} ->
"text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 cursor-not-allowed"
{"yellow", false} ->
"text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700 cursor-pointer hover:text-yellow-600 hover:border-yellow-600"
{"blue", false} ->
"text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700 cursor-pointer hover:text-blue-500 hover:border-blue-600"
{"red", false} ->
"text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700 cursor-pointer hover:text-red-500 hover:border-red-600"
{"violet", false} ->
"text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-700 cursor-pointer hover:text-violet-500 hover:border-violet-600"
end
icon_color =
if assigns.disabled do
"text-gray-400 dark:text-gray-500"
else
case assigns.color do
"yellow" -> "text-gray-500 group-hover:text-yellow-500"
"blue" -> "text-gray-500 group-hover:text-blue-500"
"red" -> "text-gray-500 group-hover:text-red-500"
"violet" -> "text-gray-500 group-hover:text-violet-500"
end
end
assigns = assign(assigns, color_classes: color_classes, icon_color: icon_color)
~H"""
<button
id={@id}
type="button"
disabled={@disabled}
data-confirm={@confirm}
data-title={@tooltip}
phx-hook={if @tooltip, do: "Tippy"}
class={[
"group inline-flex items-center justify-center h-9 pl-2.5 pr-2.5 rounded-md border text-sm font-medium transition-all duration-200",
unless(@disabled, do: "group-hover:pr-3"),
@color_classes
]}
{@rest}
>
<.button_icon name={@icon} class={["h-5 w-5 shrink-0", @icon_color]} />
<span class={[
"overflow-hidden transition-all duration-200 whitespace-nowrap",
if(@disabled, do: "max-w-0", else: "max-w-0 group-hover:max-w-24 group-hover:ml-1.5")
]}>
{@label}
</span>
</button>
"""
end
defp button_icon(%{name: "arrow_path"} = assigns), do: ~H[<Icons.arrow_path class={@class} />]
defp button_icon(%{name: "pause_circle"} = assigns),
do: ~H[<Icons.pause_circle class={@class} />]
defp button_icon(%{name: "pencil_square"} = assigns),
do: ~H[<Icons.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} />]
# Sparkline
attr :id, :string, required: true
attr :history, :map, required: true
attr :max_value, :integer, default: nil
attr :bar_width, :integer, default: 4
attr :count, :integer, default: 60
attr :gap, :integer, default: 1
attr :height, :integer, default: 16
attr :class, :string, default: nil
def sparkline(assigns) do
history = assigns.history
count = assigns.count
bar_width = assigns.bar_width
gap = assigns.gap
height = assigns.height
max_index = count - 1
max_value =
if assigns.max_value do
max(assigns.max_value, 1)
else
history
|> Map.values()
|> Enum.reduce(1, fn %{count: c}, acc -> max(c, acc) end)
end
now = System.system_time(:millisecond)
{bars, tooltip_data} =
for slot <- 0..max_index, reduce: {[], []} do
{bars_acc, tool_acc} ->
index = max_index - slot
timestamp = now - index * 5 * 1000
x = slot * (bar_width + gap)
case Map.get(history, index) do
%{count: c} ->
bar_height = min(c / max_value, 1.0) * height
bar = %{x: x, height: max(bar_height, 0)}
tooltip = %{timestamp: timestamp, count: c}
{[bar | bars_acc], [tooltip | tool_acc]}
nil ->
tooltip = %{timestamp: timestamp, count: 0}
{bars_acc, [tooltip | tool_acc]}
end
end
bars = Enum.reverse(bars)
tooltip_data = Enum.reverse(tooltip_data)
placeholders =
for slot <- 0..max_index do
%{x: slot * (bar_width + gap)}
end
width = count * (bar_width + gap)
assigns =
assigns
|> assign(bars: bars, placeholders: placeholders, width: width)
|> assign(tooltip_data: tooltip_data)
~H"""
<svg
id={@id}
width={@width}
height={@height}
viewBox={"0 0 #{@width} #{@height}"}
class={["flex-shrink-0", @class]}
phx-hook="QueueSparkline"
data-tooltip={Oban.JSON.encode!(@tooltip_data)}
data-bar-width={@bar_width}
>
<rect
:for={placeholder <- @placeholders}
x={placeholder.x}
y={@height - 2}
width={@bar_width}
height="2"
fill="#e5e7eb"
class="dark:fill-gray-700"
rx="0.5"
/>
<rect
:for={bar <- @bars}
x={bar.x}
y={@height - bar.height}
width={@bar_width}
height={bar.height}
fill="#22d3ee"
rx="1"
/>
</svg>
"""
end
end end

View file

@ -0,0 +1,192 @@
defmodule Oban.Web.FormComponents do
@moduledoc false
use Oban.Web, :html
attr :label, :string, required: true
attr :name, :string, required: true
attr :value, :any, default: nil
attr :type, :string, default: "text"
attr :colspan, :string, default: nil
attr :placeholder, :string, default: nil
attr :required, :boolean, default: false
attr :disabled, :boolean, default: false
attr :hint, :string, default: nil
attr :rows, :integer, default: 2
def form_field(assigns) do
~H"""
<div class={@colspan}>
<label
for={@name}
class="flex items-center gap-1 font-medium text-sm text-gray-700 dark:text-gray-300 mb-2"
>
{@label}
<span :if={@required} class="text-rose-500">*</span>
<span
:if={@hint}
id={"#{@name}-hint"}
data-title={@hint}
phx-hook="Tippy"
class="translate-y-px"
>
<Icons.info_circle class="w-4 h-4 text-gray-400 dark:text-gray-500" />
</span>
</label>
<%= if @type == "textarea" do %>
<textarea
id={@name}
name={@name}
rows={@rows}
disabled={@disabled}
placeholder={@placeholder}
class="block w-full font-mono text-sm shadow-sm border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-md focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
>{@value}</textarea>
<% else %>
<input
type={@type}
id={@name}
name={@name}
value={@value}
disabled={@disabled}
placeholder={@placeholder}
required={@required}
class="block w-full font-mono text-sm shadow-sm border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-md focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
/>
<% end %>
</div>
"""
end
attr :label, :string, required: true
attr :name, :string, required: true
attr :value, :any, default: nil
attr :options, :list, required: true
attr :disabled, :boolean, default: false
def select_field(assigns) do
~H"""
<div>
<label for={@name} class="block font-medium text-sm text-gray-700 dark:text-gray-300 mb-2">
{@label}
</label>
<select
id={@name}
name={@name}
disabled={@disabled}
class="block w-full font-mono text-sm shadow-sm border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-md focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
>
<option :for={{label, val} <- @options} value={val} selected={val == @value}>
{label}
</option>
</select>
</div>
"""
end
attr :label, :string, required: true
attr :name, :string, required: true
attr :checked, :boolean, default: false
attr :disabled, :boolean, default: false
attr :hint, :string, default: nil
attr :colspan, :string, default: nil
def checkbox_field(assigns) do
~H"""
<div class={[@colspan, "group"]}>
<input type="hidden" name={@name} value="false" />
<label
for={@name}
class="flex items-center font-medium text-sm text-gray-700 dark:text-gray-300 cursor-pointer group-has-[:disabled]:opacity-50 group-has-[:disabled]:cursor-not-allowed"
>
<div class="relative mr-2">
<input
type="checkbox"
id={@name}
name={@name}
value="true"
checked={@checked}
disabled={@disabled}
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" />
</div>
{@label}
<span
:if={@hint}
id={"#{@name}-hint"}
data-title={@hint}
phx-hook="Tippy"
class="ml-1 translate-y-px"
>
<Icons.info_circle class="w-4 h-4 text-gray-400 dark:text-gray-500" />
</span>
</label>
</div>
"""
end
# Form Parsing Helpers
@doc """
Parses and trims a string, returning nil for empty values.
"""
def parse_string(nil), do: nil
def parse_string(""), do: nil
def parse_string(str), do: String.trim(str)
@doc """
Parses a string to an integer, returning nil for empty or invalid values.
"""
def parse_int(nil), do: nil
def parse_int(""), do: nil
def parse_int(str) when is_binary(str) do
case Integer.parse(str) do
{num, ""} -> num
_ -> nil
end
end
@doc """
Parses a comma-separated string into a list of trimmed tags. Returns nil for empty input.
"""
def parse_tags(nil), do: nil
def parse_tags(""), do: nil
def parse_tags(str) when is_binary(str) do
str
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> case do
[] -> nil
tags -> tags
end
end
@doc """
Parses a JSON string into a map, returning nil for empty or invalid values.
"""
def parse_json(nil), do: nil
def parse_json(""), do: nil
def parse_json(str) when is_binary(str) do
case Oban.JSON.decode!(str) do
map when is_map(map) -> map
_ -> nil
end
rescue
_ -> nil
end
@doc """
Builds queue options from a list of queue structs.
"""
def queue_options(queues) do
queues
|> Enum.map(fn %{name: name} -> {name, name} end)
|> Enum.sort_by(&elem(&1, 0))
end
end

View file

@ -1,670 +1,64 @@
defmodule Oban.Web.Components.Icons do defmodule Oban.Web.Components.Icons do
use Oban.Web, :html @moduledoc false
# Helpers use Phoenix.Component
attr :rest, :global, @icons_path Application.app_dir(:oban_web, ["priv", "static", "icons"])
default: %{
"aria-hidden": "true",
class: "w-4 h-4",
fill: "currentColor",
viewBox: "0 0 16 16"
}
slot :inner_block, required: true @outline_path Path.join(@icons_path, "outline")
@solid_path Path.join(@icons_path, "solid")
defp svg_mini(assigns) do @outline_icons @outline_path
~H""" |> File.ls!()
<svg {@rest}> |> Enum.filter(&String.ends_with?(&1, ".svg"))
{render_slot(@inner_block)} |> Enum.map(&String.trim_trailing(&1, ".svg"))
</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
end end
attr :rest, :global, for icon <- @solid_icons do
default: %{ @external_resource Path.join(@solid_path, "#{icon}.svg")
"stroke-width": "1.5",
class: "w-6 h-6",
fill: "none",
stroke: "currentColor",
viewBox: "0 0 24 24"
}
slot :inner_block, required: true func_name =
icon
|> String.replace("-", "_")
|> Kernel.<>("_solid")
|> String.to_atom()
defp svg_outline(assigns) do @doc false
~H""" attr :class, :any, default: "w-6 h-6"
<svg {@rest}> attr :rest, :global
{render_slot(@inner_block)}
</svg> def unquote(func_name)(assigns) do
""" assigns = assign(assigns, :icon_path, "solid/#{unquote(icon)}.svg")
render_icon(assigns)
end
end end
attr :rest, :global, # Inline Icons
default: %{
"aria-hidden": "true",
class: "w-6 h-6",
fill: "currentColor",
viewBox: "0 0 24 24"
}
slot :inner_block, required: true attr :rest, :global, default: %{class: "w-6 h-6"}
defp svg_solid(assigns) do def oban_pro_logo(assigns) do
~H"""
<svg xmlns="http://www.w3.org/2000/svg" {@rest}>
{render_slot(@inner_block)}
</svg>
"""
end
# Icons
attr :rest, :global
def adjustments_horizontal(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def adjustments_vertical(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def arrow_left(assigns) do
~H"""
<.svg_outline {@rest}>
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
</.svg_outline>
"""
end
attr :rest, :global
def arrow_path(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def arrow_path_rounded(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def arrow_right_circle(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def arrow_top_right_on_square(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def arrow_trending_down(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def calendar_days(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def crossbones_circle(assigns) do
~H"""
<.svg_solid {@rest}>
<path d="M12 2c5.523 0 10 4.478 10 10 0 5.523-4.477 10-10 10-5.522 0-10-4.477-10-10C2 6.478 6.478 2 12 2Zm0 1.363a8.637 8.637 0 1 0 0 17.274 8.637 8.637 0 1 0 0-17.274Zm4.358 11.077a.625.625 0 0 1-.344.814l-2.374.965 2.375.967a.625.625 0 0 1 .37.733l-.027.08a.625.625 0 0 1-.815.344l-3.56-1.45-3.558 1.45a.625.625 0 0 1-.472-1.158l2.373-.966-2.373-.965a.625.625 0 0 1-.37-.733l.027-.081a.625.625 0 0 1 .814-.344l3.559 1.449 3.56-1.449c.32-.13.684.024.815.344ZM12.023 5.61c2.16 0 3.913 1.546 3.864 3.455 0 1.257-.743 2.348-1.877 2.952l.149.699c.071.341-.146.672-.448.672h-3.424c-.298 0-.494-.33-.423-.672l.125-.697c-1.111-.605-1.878-1.697-1.878-2.976 0-1.886 1.753-3.433 3.913-3.433Zm1.435 1.945a.975.975 0 0 0-.973.972c0 .535.437.972.973.972a.973.973 0 0 0 0-1.944Zm-2.917 0a.993.993 0 0 0-.972.972c0 .535.41.972.973.972a.958.958 0 0 0 .972-.972.974.974 0 0 0-.972-.972h-.001Z" />
</.svg_solid>
"""
end
attr :rest, :global
def bars_arrow_down(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def bars_arrow_up(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def bolt_circle(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def bolt_slash(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def camera(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def chart_bar_square(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def check(assigns) do
~H"""
<.svg_outline {@rest}>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</.svg_outline>
"""
end
attr :rest, :global
def indeterminate(assigns) do
~H"""
<.svg_outline {@rest}>
<line stroke-linecap="round" stroke-linejoin="round" x1="5" x2="19" y1="12" y2="12"></line>
</.svg_outline>
"""
end
attr :rest, :global
def check_circle(assigns) do
~H"""
<.svg_outline {@rest}>
<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_outline>
"""
end
attr :rest, :global
def chevron_down(assigns) do
~H"""
<.svg_outline {@rest}>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</.svg_outline>
"""
end
attr :rest, :global
def chevron_right(assigns) do
~H"""
<.svg_outline {@rest}>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</.svg_outline>
"""
end
attr :rest, :global
def chevron_up(assigns) do
~H"""
<.svg_outline {@rest}>
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</.svg_outline>
"""
end
attr :rest, :global
def clock(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def cog(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.5 12a7.5 7.5 0 0015 0m-15 0a7.5 7.5 0 1115 0m-15 0H3m16.5 0H21m-1.5 0H12m-8.457 3.077l1.41-.513m14.095-5.13l1.41-.513M5.106 17.785l1.15-.964m11.49-9.642l1.149-.964M7.501 19.795l.75-1.3m7.5-12.99l.75-1.3m-6.063 16.658l.26-1.477m2.605-14.772l.26-1.477m0 17.726l-.26-1.477M10.698 4.614l-.26-1.477M16.5 19.794l-.75-1.299M7.5 4.205L12 12m6.894 5.785l-1.149-.964M6.256 7.178l-1.15-.964m15.352 8.864l-1.41-.513M4.954 9.435l-1.41-.514M12.002 12l-3.75 6.495"
/>
</.svg_outline>
"""
end
attr :rest, :global
def command_line(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def computer_desktop(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"
/>
</.svg_outline>
"""
end
attr :rest, :global
def exclamation_circle(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def globe(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</.svg_outline>
"""
end
attr :rest, :global
def info_circle(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def hashtag(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5l-3.9 19.5m-2.1-19.5l-3.9 19.5"
/>
</.svg_outline>
"""
end
attr :rest, :global
def life_buoy(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.712 4.33a9.027 9.027 0 0 1 1.652 1.306c.51.51.944 1.064 1.306 1.652M16.712 4.33l-3.448 4.138m3.448-4.138a9.014 9.014 0 0 0-9.424 0M19.67 7.288l-4.138 3.448m4.138-3.448a9.014 9.014 0 0 1 0 9.424m-4.138-5.976a3.736 3.736 0 0 0-.88-1.388 3.737 3.737 0 0 0-1.388-.88m2.268 2.268a3.765 3.765 0 0 1 0 2.528m-2.268-4.796a3.765 3.765 0 0 0-2.528 0m4.796 4.796c-.181.506-.475.982-.88 1.388a3.736 3.736 0 0 1-1.388.88m2.268-2.268 4.138 3.448m0 0a9.027 9.027 0 0 1-1.306 1.652c-.51.51-1.064.944-1.652 1.306m0 0-3.448-4.138m3.448 4.138a9.014 9.014 0 0 1-9.424 0m5.976-4.138a3.765 3.765 0 0 1-2.528 0m0 0a3.736 3.736 0 0 1-1.388-.88 3.737 3.737 0 0 1-.88-1.388m2.268 2.268L7.288 19.67m0 0a9.024 9.024 0 0 1-1.652-1.306 9.027 9.027 0 0 1-1.306-1.652m0 0 4.138-3.448M4.33 16.712a9.014 9.014 0 0 1 0-9.424m4.138 5.976a3.765 3.765 0 0 1 0-2.528m0 0c.181-.506.475-.982.88-1.388a3.736 3.736 0 0 1 1.388-.88m-2.268 2.268L4.33 7.288m6.406 1.18L7.288 4.33m0 0a9.024 9.024 0 0 0-1.652 1.306A9.025 9.025 0 0 0 4.33 7.288"
/>
</.svg_outline>
"""
end
attr :rest, :global
def lock_closed(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def map_pin(assigns) do
~H"""
<.svg_outline {@rest}>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def magnifying_glass(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def minus_circle(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def moon(assigns) do
~H"""
<.svg_outline {@rest}>
<path d="M17.715 15.15A6.5 6.5 0 0 1 9 6.035C6.106 6.922 4 9.645 4 12.867c0 3.94 3.153 7.136 7.042 7.136 3.101 0 5.734-2.032 6.673-4.853Z">
</path>
</.svg_outline>
"""
end
attr :rest, :global
def no_symbol(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</.svg_outline>
"""
end
attr :rest, :global
def pause_circle(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.25 9v6m-4.5 0V9M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def pause_circle_solid(assigns) do
~H"""
<.svg_solid {@rest}>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12ZM9 8.25a.75.75 0 0 0-.75.75v6c0 .414.336.75.75.75h.75a.75.75 0 0 0 .75-.75V9a.75.75 0 0 0-.75-.75H9Zm5.25 0a.75.75 0 0 0-.75.75v6c0 .414.336.75.75.75H15a.75.75 0 0 0 .75-.75V9a.75.75 0 0 0-.75-.75h-.75Z"
clip-rule="evenodd"
/>
</.svg_solid>
"""
end
attr :rest, :global
def percent_square(assigns) do
~H"""
<.svg_outline {@rest}>
<path
d="M6 4h12.5a2 2 0 0 1 2 2v12.5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Zm2 13 9-9m-6.313 2.125a.563.563 0 1 1-1.125 0 .563.563 0 0 1 1.126 0Zm4.75 4.75a.562.562 0 1 1-1.124 0 .562.562 0 0 1 1.124 0Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
</.svg_outline>
"""
end
attr :rest, :global
def play_circle(assigns) do
~H"""
<.svg_outline {@rest}>
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def play_circle_solid(assigns) do
~H"""
<.svg_solid {@rest}>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm14.024-.983a1.125 1.125 0 0 1 0 1.966l-5.603 3.113A1.125 1.125 0 0 1 9 15.113V8.887c0-.857.921-1.4 1.671-.983l5.603 3.113Z"
clip-rule="evenodd"
/>
</.svg_solid>
"""
end
attr :rest, :global
def play_pause_circle(assigns) do
~H"""
<.svg_outline {@rest}>
<path
d="M17 9v6m-2.455-6v6M7 14.32V9.68c0-.494.509-.803.918-.558l3.877 2.32c.191.115.31.328.31.559 0 .23-.119.443-.31.558l-3.877 2.32a.59.59 0 0 1-.613-.002.65.65 0 0 1-.305-.556h0ZM12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
</.svg_outline>
"""
end
attr :rest, :global
def plus_circle(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def power(assigns) do
~H"""
<.svg_outline {@rest}>
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
</.svg_outline>
"""
end
attr :rest, :global
def soren_logo(assigns) do
~H""" ~H"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {@rest}> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {@rest}>
<g fill="none" fill-rule="evenodd"> <g fill="none" fill-rule="evenodd">
@ -722,156 +116,47 @@ defmodule Oban.Web.Components.Icons do
""" """
end end
attr :rest, :global attr :rest, :global, default: %{class: "w-6 h-6"}
def square_stack(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.429 9.75L2.25 12l4.179 2.25m0-4.5l5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0l4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0l-5.571 3-5.571-3"
/>
</.svg_outline>
"""
end
attr :rest, :global
def square_2x2(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def rectangle_group(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def spinner(assigns) do def spinner(assigns) do
~H""" ~H"""
<svg viewBox="0 0 100 101" fill="none" {@rest}> <svg viewBox="0 0 24 24" fill="none" {@rest}>
<path <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.5" opacity="0.25" />
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" <path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg> </svg>
""" """
end end
attr :rest, :global 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)
def table_cells(assigns) do
~H""" ~H"""
<.svg_outline {@rest}> <span
<path class={["block bg-current", @normalized_class]}
stroke-linecap="round" style={"mask-image: url(#{@icon_url}); -webkit-mask-image: url(#{@icon_url}); mask-size: contain; mask-repeat: no-repeat; mask-position: center;"}
stroke-linejoin="round" aria-hidden="true"
d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 01-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0112 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M13.125 12h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125M20.625 12c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5M12 14.625v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 14.625c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 1.5v-1.5m0 0c0-.621.504-1.125 1.125-1.125m0 0h7.5" {@rest}
/> />
</.svg_outline>
""" """
end end
attr :rest, :global defp icon_url(path) do
case Process.get(:routing) do
{socket, prefix} ->
Phoenix.VerifiedRoutes.unverified_path(
socket,
socket.router,
"#{prefix}/icons/#{path}"
)
def sparkles(assigns) do _ ->
~H""" "/icons/#{path}"
<.svg_outline {@rest}> end
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"
/>
</.svg_outline>
"""
end end
attr :rest, :global defp normalize_class(class) when is_binary(class), do: class
defp normalize_class(class) when is_list(class), do: class
def sun(assigns) do defp normalize_class(nil), do: "w-6 h-6"
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def trash(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</.svg_outline>
"""
end
attr :rest, :global
def user_circle(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def x_circle(assigns) do
~H"""
<.svg_outline {@rest}>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</.svg_outline>
"""
end
attr :rest, :global
def x_mark(assigns) do
~H"""
<.svg_outline {@rest}>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</.svg_outline>
"""
end
end end

View file

@ -119,11 +119,11 @@ defmodule Oban.Web.Layouts do
<div class="max-w-sm w-full bg-white dark:bg-black dark:bg-opacity-90 shadow-lg rounded-lg pointer-events-auto"> <div class="max-w-sm w-full bg-white dark:bg-black dark:bg-opacity-90 shadow-lg rounded-lg pointer-events-auto">
<div class="rounded-lg ring-1 ring-black/5 overflow-hidden"> <div class="rounded-lg ring-1 ring-black/5 overflow-hidden">
<div class="p-4"> <div class="p-4">
<div class="flex items-start"> <div class="flex items-center">
<div class="flex-shrink-0 text-green-400"> <div class="flex-shrink-0 text-green-400">
<Icons.check_circle /> <Icons.check_circle />
</div> </div>
<div class="ml-3 w-0 flex-1 pt-0.5"> <div class="ml-3 w-0 flex-1">
<p class="text-sm leading-5 font-medium text-gray-900 dark:text-gray-100"> <p class="text-sm leading-5 font-medium text-gray-900 dark:text-gray-100">
{Phoenix.Flash.get(assigns.flash, :info)} {Phoenix.Flash.get(assigns.flash, :info)}
</p> </p>
@ -131,7 +131,7 @@ defmodule Oban.Web.Layouts do
<div class="ml-4 flex-shrink-0 flex"> <div class="ml-4 flex-shrink-0 flex">
<button <button
phx-click="lv:clear-flash" phx-click="lv:clear-flash"
class="inline-flex text-gray-400 dark:text-gray-600 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 focus:text-gray-500 transition ease-in-out duration-150" 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.x_mark class="w-5 h-5" />
</button> </button>
@ -148,7 +148,7 @@ defmodule Oban.Web.Layouts do
~H""" ~H"""
<nav class="flex space-x-2"> <nav class="flex space-x-2">
<.link <.link
:for={page <- [:jobs, :queues]} :for={page <- [:jobs, :queues, :crons, :workflows]}
class={link_class(@page, page)} class={link_class(@page, page)}
data-shortcut={JS.patch(oban_path(page))} data-shortcut={JS.patch(oban_path(page))}
id={"nav-#{page}"} id={"nav-#{page}"}
@ -178,7 +178,7 @@ defmodule Oban.Web.Layouts do
<.version name="Oban.Pro" version={@pro_version} /> <.version name="Oban.Pro" version={@pro_version} />
<a href="https://oban.pro" class="flex items-center space-x-2 group"> <a href="https://oban.pro" class="flex items-center space-x-2 group">
<Icons.soren_logo class="w-6 h-6" /> <Icons.oban_pro_logo class="w-6 h-6" />
<span class="text-sky-900 dark:text-sky-300 group-hover:underline font-semibold"> <span class="text-sky-900 dark:text-sky-300 group-hover:underline font-semibold">
Made by Oban Pro Made by Oban Pro
</span> </span>

View file

@ -18,6 +18,7 @@
<.live_component module={Oban.Web.ConnectivityComponent} id="connectivity" conf={@conf} /> <.live_component module={Oban.Web.ConnectivityComponent} id="connectivity" conf={@conf} />
<.live_component module={Oban.Web.ThemeComponent} id="theme" theme={@theme} /> <.live_component module={Oban.Web.ThemeComponent} id="theme" theme={@theme} />
<.live_component module={Oban.Web.RefreshComponent} id="refresh" refresh={@refresh} /> <.live_component module={Oban.Web.RefreshComponent} id="refresh" refresh={@refresh} />
<.live_component module={Oban.Web.HelpComponent} id="help" />
<.live_component <.live_component
module={Oban.Web.InstancesComponent} module={Oban.Web.InstancesComponent}
id="instances" id="instances"

View file

@ -36,7 +36,7 @@ defmodule Oban.Web.SidebarComponents do
<header class="flex justify-between items-center py-3"> <header class="flex justify-between items-center py-3">
<button <button
id={"#{@name}-toggle"} id={"#{@name}-toggle"}
class="text-gray-400 hover:text-violet-500 dark:text-gray-600 dark:hover:text-violet-500" class="text-gray-400 hover:text-violet-500 dark:text-gray-500 dark:hover:text-violet-500"
data-title={"Toggle #{@name}"} data-title={"Toggle #{@name}"}
phx-click={toggle(@name)} phx-click={toggle(@name)}
phx-hook="Tippy" phx-hook="Tippy"
@ -47,7 +47,7 @@ defmodule Oban.Web.SidebarComponents do
<h3 class="dark:text-gray-200 font-bold">{String.capitalize(@name)}</h3> <h3 class="dark:text-gray-200 font-bold">{String.capitalize(@name)}</h3>
<div class="ml-auto flex space-x-2 text-right text-xs tracking-tight text-gray-600 dark:text-gray-400 uppercase"> <div class="ml-auto flex space-x-2 text-right text-xs tracking-tight text-gray-600 dark:text-gray-400 uppercase">
<span :for={header <- @headers} class="w-10">{header}</span> <span :for={header <- @headers} class="w-12">{header}</span>
</div> </div>
</header> </header>

16
vendor/oban_web/lib/oban/web/cron.ex vendored Normal file
View file

@ -0,0 +1,16 @@
defmodule Oban.Web.Cron do
@moduledoc false
defstruct [
:name,
:expression,
:worker,
:opts,
:next_at,
:last_at,
:last_state,
dynamic?: false,
paused?: false,
history: []
]
end

View file

@ -0,0 +1,348 @@
defmodule Oban.Web.CronExpr do
@moduledoc false
@days_of_week_names %{
0 => "Sunday",
1 => "Monday",
2 => "Tuesday",
3 => "Wednesday",
4 => "Thursday",
5 => "Friday",
6 => "Saturday"
}
@days_of_week_translations %{
"SUN" => 0,
"MON" => 1,
"TUE" => 2,
"WED" => 3,
"THU" => 4,
"FRI" => 5,
"SAT" => 6
}
@doc """
Convert a cron expression into a human-readable description.
Returns a friendly string for common patterns, or nil for complex patterns
that don't match known templates.
"""
def describe(expression) when is_binary(expression) do
case expression do
"@yearly" -> "Yearly on January 1st"
"@annually" -> "Yearly on January 1st"
"@monthly" -> "Monthly on the 1st"
"@weekly" -> "Weekly on Sunday"
"@daily" -> "Daily at midnight"
"@midnight" -> "Daily at midnight"
"@hourly" -> "Every hour"
"@reboot" -> "At system reboot"
_ -> describe_parsed(expression)
end
end
def describe(_), do: nil
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
combine_description(parsed_min, parsed_hrs, parsed_dom, parsed_dow)
else
_ -> nil
end
end
# Field Parsing
defp parse_field(field, translations \\ %{}) do
parts =
field
|> String.split(",")
|> Enum.map(&parse_part(&1, translations))
if Enum.any?(parts, &(&1 == :error)) do
:error
else
{:ok, parts}
end
end
defp parse_part("*", _translations), do: :wildcard
defp parse_part("*/" <> step, _translations) do
case Integer.parse(step) do
{num, ""} when num > 0 -> {:step, num}
_ -> :error
end
end
defp parse_part(part, translations) do
if String.contains?(part, "-") do
parse_range(part, translations)
else
parse_value(part, translations)
end
end
defp parse_range(part, 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
{: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
end
end
defp translate_or_parse(str, translations) do
upper = String.upcase(str)
with :error <- Map.fetch(translations, upper) do
case Integer.parse(str) do
{num, ""} -> {:ok, num}
_ -> :error
end
end
end
# Description Combination
defp combine_description(min, hrs, dom, dow) do
if dom == [:wildcard] and dow == [:wildcard] do
describe_time(min, hrs)
else
combine_date_time(min, hrs, dom, dow)
end
end
defp describe_time([:wildcard], [:wildcard]), do: "Every minute"
defp describe_time([{:step, 1}], [:wildcard]), do: "Every minute"
defp describe_time([{:step, step}], [:wildcard]), do: "Every #{step} minutes"
defp describe_time([{:value, 0}], [:wildcard]), do: "Every hour"
defp describe_time([{:value, 0}], [{:step, 1}]), do: "Every hour"
defp describe_time([{:value, 0}], [{:step, step}]), do: "Every #{step} hours"
defp describe_time([{:value, min}], [{:step, step}]) when min in 0..59 do
"Every #{step} hours at :#{String.pad_leading("#{min}", 2, "0")}"
end
defp describe_time([{:value, 0}], [{:value, 0}]), do: "Daily at midnight"
defp describe_time([{:value, 0}], [{:value, 12}]), do: "Daily at noon"
defp describe_time([{:value, min}], [{:value, hr}]) when min in 0..59 and hr in 0..23 do
"Daily at #{format_time_24h(hr, min)}"
end
defp describe_time([{:value, min}], hours) when is_list(hours) and min in 0..59 do
times =
Enum.map(hours, fn
{:value, hr} when hr in 0..23 -> format_time_24h(hr, min)
_ -> nil
end)
if Enum.any?(times, &is_nil/1) do
nil
else
"Daily at #{format_list(times)}"
end
end
defp describe_time(_, _), do: nil
defp combine_date_time(min, hrs, dom, dow) do
time = extract_time(min, hrs)
date_desc = describe_date(dom, dow)
case {time, date_desc} do
{nil, _} -> nil
{_, nil} -> nil
{{0, 0}, desc} -> "#{desc} at 0:00"
{{hrs, min}, desc} -> "#{desc} at #{format_time_24h(hrs, min)}"
end
end
defp extract_time([{:value, min}], [{:value, hrs}]) when min in 0..59 and hrs in 0..23 do
{hrs, min}
end
defp extract_time(_, _), do: nil
# Date Description (DOM and DOW combination)
defp describe_date([:wildcard], dow) do
describe_dow(dow)
end
defp describe_date(dom, [:wildcard]) do
describe_dom(dom)
end
defp describe_date(dom, dow) do
describe_combined_dom_dow(dom, dow)
end
# Day of Month Description
defp describe_dom([{:value, day}]) when day in 1..31 do
"Monthly on the #{ordinal(day)}"
end
defp describe_dom(parts) when is_list(parts) do
values = expand_dom_parts(parts)
case excluded_dom(values) do
{:ok, day} ->
"Daily except the #{ordinal(day)}"
:error ->
days = extract_dom_values(parts)
if days != [] do
"On the #{format_list(Enum.map(days, &ordinal/1))}"
else
nil
end
end
end
defp describe_dom(_), do: nil
defp excluded_dom(values) do
case Enum.to_list(1..31) -- values do
[excluded] -> {:ok, excluded}
_ -> :error
end
end
defp expand_dom_parts(parts) do
parts
|> Enum.flat_map(fn
{:value, val} -> [val]
{:range, start_val, end_val} -> Enum.to_list(start_val..end_val)
_ -> []
end)
|> Enum.sort()
end
defp extract_dom_values(parts) do
Enum.flat_map(parts, fn
{:value, val} when val in 1..31 ->
[val]
{:range, start_val, end_val} when start_val in 1..31 and end_val in 1..31 ->
Enum.to_list(start_val..end_val)
_ ->
[]
end)
end
# Day of Week Description
defp describe_dow([{:value, day}]) when day in 0..7 do
"Weekly on #{day_name(normalize_dow(day))}"
end
defp describe_dow(parts) when is_list(parts) do
values = expand_dow_parts(parts)
cond do
values == [1, 2, 3, 4, 5] ->
"Weekdays"
values == [0, 6] ->
"Weekends"
length(values) == 6 ->
{:ok, day} = excluded_dow(values)
"Daily except #{day_name(day)}s"
values != [] ->
"On #{format_list(Enum.map(values, &day_name/1))}"
true ->
nil
end
end
defp describe_dow(_), do: nil
defp expand_dow_parts(parts) do
parts
|> Enum.flat_map(fn
{:value, val} -> [normalize_dow(val)]
{:range, start_val, end_val} -> Enum.map(start_val..end_val, &normalize_dow/1)
_ -> []
end)
|> Enum.uniq()
|> Enum.sort()
end
defp normalize_dow(7), do: 0
defp normalize_dow(day), do: day
defp excluded_dow(values) do
case Enum.to_list(0..6) -- values do
[excluded] -> {:ok, excluded}
_ -> :error
end
end
# Combined DOM and DOW description
defp describe_combined_dom_dow(dom, dow) do
dom_values = expand_dom_parts(dom)
dow_values = expand_dow_parts(dow)
case {dom_values, excluded_dom(dom_values), dow_values, excluded_dow(dow_values)} do
{[dom_day], _, [dow_day], _} ->
"The #{ordinal(dom_day)}, only on #{day_name(dow_day)}s"
{[dom_day], _, _, {:ok, excluded}} ->
"The #{ordinal(dom_day)}, except #{day_name(excluded)}s"
{_, {:ok, excluded}, [dow_day], _} ->
"#{day_name(dow_day)}s, except the #{ordinal(excluded)}"
{_, {:ok, dom_excluded}, _, {:ok, dow_excluded}} ->
"Daily except the #{ordinal(dom_excluded)} and #{day_name(dow_excluded)}s"
_ ->
nil
end
end
# Helper functions
defp day_name(num), do: Map.get(@days_of_week_names, num, "Unknown")
defp format_time_24h(hr, min) do
"#{hr}:#{String.pad_leading("#{min}", 2, "0")}"
end
defp format_list([single]), do: single
defp format_list([first, second]), do: "#{first} and #{second}"
defp format_list(items) when length(items) > 2 do
{init, [last]} = Enum.split(items, -1)
"#{Enum.join(init, ", ")}, and #{last}"
end
defp format_list(_), do: nil
defp ordinal(num) when num in [1, 21, 31], do: "#{num}st"
defp ordinal(num) when num in [2, 22], do: "#{num}nd"
defp ordinal(num) when num in [3, 23], do: "#{num}rd"
defp ordinal(num), do: "#{num}th"
end

View file

@ -1,7 +1,7 @@
defmodule Oban.Web.DashboardLive do defmodule Oban.Web.DashboardLive do
use Oban.Web, :live_view use Oban.Web, :live_view
alias Oban.Web.{JobsPage, QueuesPage} alias Oban.Web.{CronsPage, JobsPage, QueuesPage, WorkflowsPage}
@impl Phoenix.LiveView @impl Phoenix.LiveView
def mount(params, session, socket) do def mount(params, session, socket) do
@ -15,8 +15,8 @@ defmodule Oban.Web.DashboardLive do
sidebar_width = restore_state(socket, "sidebar_width", 320) sidebar_width = restore_state(socket, "sidebar_width", 320)
oban = current_oban_instance(session, socket) oban = current_oban_instance(session, socket)
conf = await_init([oban], :supervisor) conf = await_init([oban])
_met = await_init([oban, Oban.Met], :met) _met = await_init([oban, Oban.Met])
page = resolve_page(params) page = resolve_page(params)
Process.put(:routing, {socket, prefix}) Process.put(:routing, {socket, prefix})
@ -196,46 +196,32 @@ defmodule Oban.Web.DashboardLive do
## Mount Helpers ## Mount Helpers
defp await_init([oban_name | _] = args, proc, timeout \\ 15_000) do defp await_init(args, opts \\ [])
defp await_init([oban_name | _] = args, opts) do
retries = Keyword.get(opts, :retries, 150)
timeout = Keyword.get(opts, :timeout, 100)
case apply(Oban.Registry, :whereis, args) do case apply(Oban.Registry, :whereis, args) do
nil when retries > 0 ->
Process.sleep(timeout)
await_init(args, Keyword.put(opts, :retries, retries - 1))
nil -> nil ->
ref = make_ref() raise RuntimeError, "no config registered for #{inspect(args)} instance"
:telemetry.attach(
ref,
[:oban, proc, :init],
&__MODULE__.relay_init/4,
{args, self()}
)
receive do
{^args, %{name: ^oban_name} = conf} ->
:telemetry.detach(ref)
# Sleep briefly to prevent race conditions between init and child process
# initialization.
Process.sleep(5)
conf
after
timeout ->
raise RuntimeError, "no config registered for #{inspect(args)} instance"
end
pid when is_pid(pid) -> pid when is_pid(pid) ->
Oban.config(oban_name) Oban.config(oban_name)
end end
end end
@doc false
def relay_init(_event, _timing, %{conf: conf}, {args, pid}) do
send(pid, {args, conf})
end
## Render Helpers ## Render Helpers
defp resolve_page(%{"page" => "jobs"}), do: %{name: :jobs, comp: JobsPage} defp resolve_page(%{"page" => "jobs"}), do: %{name: :jobs, comp: JobsPage}
defp resolve_page(%{"page" => "queues"}), do: %{name: :queues, comp: QueuesPage} defp resolve_page(%{"page" => "queues"}), do: %{name: :queues, comp: QueuesPage}
defp resolve_page(%{"page" => "crons"}), do: %{name: :crons, comp: CronsPage}
defp resolve_page(%{"page" => "workflows"}), do: %{name: :workflows, comp: WorkflowsPage}
defp resolve_page(_params), do: %{name: :jobs, comp: JobsPage} defp resolve_page(_params), do: %{name: :jobs, comp: JobsPage}
## Refresh Helpers ## Refresh Helpers

View file

@ -109,7 +109,8 @@ defmodule Oban.Web.Helpers do
{String.to_existing_atom(key), val} {String.to_existing_atom(key), val}
{key, val} when key in ~w(ids modes nodes priorities queues stats tags workers) -> {key, val}
when key in ~w(ids modes names nodes priorities queues stats states tags workers) ->
{String.to_existing_atom(key), String.split(val, ",")} {String.to_existing_atom(key), String.split(val, ",")}
{key, val} -> {key, val} ->
@ -117,8 +118,6 @@ defmodule Oban.Web.Helpers do
end) end)
end end
@doc """
"""
def active_filter?(params, :state, value) do def active_filter?(params, :state, value) do
params[:state] == value or (is_nil(params[:state]) and value == "executing") params[:state] == value or (is_nil(params[:state]) and value == "executing")
end end

View file

@ -47,7 +47,7 @@ defmodule Oban.Web.ConnectivityComponent do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div id="connectivity" data-title={@title} phx-hook="Tippy"> <div id="connectivity" data-title={@title} phx-hook="Tippy">
<Icons.bolt_circle :if={@status == :solitary} class="w-6 h-6 text-orange-500" /> <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.bolt_slash :if={@status == :isolated} class="w-6 h-6 animate-pulse text-red-500" />
<Icons.exclamation_circle <Icons.exclamation_circle
:if={@status == :disconnected} :if={@status == :disconnected}

View file

@ -0,0 +1,450 @@
defmodule Oban.Web.Crons.DetailComponent do
use Oban.Web, :live_component
import Oban.Web.Crons.Helpers, only: [state_icon: 1, maybe_to_unix: 1, show_name?: 1]
import Oban.Web.FormComponents
alias Oban.Job
alias Oban.Pro.Plugins.DynamicCron
alias Oban.Web.{CronExpr, Timezones}
@compile {:no_warn_undefined, DynamicCron}
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div id="cron-details" phx-window-keydown="keydown" phx-target={@myself}>
<div class="flex justify-between items-center px-3 py-4 border-b border-gray-200 dark:border-gray-700">
<.link
patch={oban_path(:crons, @params)}
id="back-link"
class="flex items-center hover:text-blue-500"
data-title="Back to crons"
phx-hook="Tippy"
>
<Icons.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">
({@cron.name})
</span>
</span>
</.link>
<div class="flex space-x-3">
<Core.status_badge :if={@cron.dynamic?} icon="sparkles" label="Dynamic" />
<Core.icon_button
id="run-now-button"
icon="play_circle"
label="Run Now"
color="blue"
tooltip="Insert a job for this cron immediately"
phx-click="run-now"
phx-target={@myself}
/>
<Core.icon_button
id="toggle-pause-button"
icon={if @cron.paused?, do: "play_circle", else: "pause_circle"}
label={if @cron.paused?, do: "Resume", else: "Pause"}
color="yellow"
tooltip={if @cron.paused?, do: "Resume scheduling jobs", else: "Pause scheduling jobs"}
disabled={not @cron.dynamic?}
phx-click="toggle-pause"
phx-target={@myself}
/>
<Core.icon_button
id="delete-cron-button"
icon="trash"
label="Delete"
color="red"
tooltip="Delete this cron"
disabled={not @cron.dynamic?}
confirm="Are you sure you want to delete this cron?"
phx-click="delete-cron"
phx-target={@myself}
/>
</div>
</div>
<div class="grid grid-cols-3 gap-6 px-3 py-6">
<div class="col-span-2">
<.history_chart cron={@cron} />
</div>
<div class="col-span-1">
<div class="flex justify-between mb-6 pr-6">
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Last Run
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
<span
id="cron-last-time"
data-timestamp={maybe_to_unix(@cron.last_at)}
phx-hook="Relativize"
phx-update="ignore"
>
-
</span>
</span>
</div>
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Next Run
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
<span
id="cron-next-time"
data-timestamp={maybe_to_unix(@cron.next_at)}
phx-hook="Relativize"
phx-update="ignore"
>
-
</span>
</span>
</div>
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Last Status
</span>
<div class="flex items-center space-x-1">
<.state_icon state={@cron.last_state} paused={@cron.paused?} />
<span class="text-base text-gray-800 dark:text-gray-200">
{if @cron.paused?, do: "Paused", else: state_label(@cron.last_state)}
</span>
</div>
</div>
</div>
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Schedule
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
<code class="font-mono">{@cron.expression}</code>
<span
:if={CronExpr.describe(@cron.expression)}
class="ml-2 text-gray-500 dark:text-gray-400"
>
({CronExpr.describe(@cron.expression)})
</span>
</span>
</div>
</div>
</div>
<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 />
<span>Edit Configuration</span>
</h3>
<fieldset disabled={not @cron.dynamic?}>
<form
id="cron-form"
class="grid grid-cols-4 gap-4 bg-gray-50 dark:bg-gray-800 rounded-md p-4"
phx-change="form-change"
phx-submit="save-cron"
phx-target={@myself}
>
<.form_field label="Schedule" name="expression" value={@cron.expression} />
<.form_field label="Worker" name="worker" value={@cron.worker} />
<.select_field
label="Queue"
name="queue"
value={get_opt(@cron, "queue") || "default"}
options={queue_options(@queues)}
/>
<.select_field
label="Timezone"
name="timezone"
value={get_opt(@cron, "timezone") || "Etc/UTC"}
options={Timezones.options_with_blank()}
/>
<div class="grid grid-cols-2 gap-2">
<.form_field
label="Priority"
name="priority"
value={get_opt(@cron, "priority")}
type="number"
placeholder="0"
/>
<.form_field
label="Max Attempts"
name="max_attempts"
value={get_opt(@cron, "max_attempts")}
type="number"
placeholder="20"
/>
</div>
<.form_field label="Tags" name="tags" value={format_tags(@cron)} placeholder="tag1, tag2" />
<.form_field
label="Args"
name="args"
value={format_args(@cron)}
colspan="col-span-2"
type="textarea"
placeholder="{}"
rows={1}
/>
<div class="col-span-2 flex items-end gap-4 p-3 bg-violet-50 dark:bg-violet-950/30 rounded-md ring-1 ring-violet-200 dark:ring-violet-800">
<.form_field
label="Name"
name="name"
value={@cron.name}
disabled={not @cron.dynamic?}
hint="Changing the name will reset cron history"
colspan="flex-1"
/>
<div class="pb-2 pr-6">
<.checkbox_field
label="Guaranteed"
name="guaranteed"
checked={get_opt(@cron, "guaranteed") == true}
disabled={not @cron.dynamic?}
hint="Ensures a job is inserted even if the scheduled time passed"
/>
</div>
</div>
<div class="col-span-2 flex justify-end items-center gap-3 pt-6">
<a
:if={not @cron.dynamic?}
rel="static-blocker"
href="https://oban.pro/docs/pro/Oban.Pro.Plugins.DynamicCron.html"
target="_blank"
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" />
</a>
<.save_button disabled={not @changed?} />
</div>
</form>
</fieldset>
</div>
</div>
"""
end
attr :cron, :any, required: true
defp history_chart(assigns) do
~H"""
<div class="group relative">
<div
id="cron-chart"
class="h-48 bg-gray-50 dark:bg-gray-800 rounded-md p-4"
phx-hook="CronChart"
phx-update="ignore"
>
</div>
<.link
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" />
</.link>
</div>
"""
end
attr :disabled, :boolean, default: false
defp save_button(assigns) do
~H"""
<button
type="submit"
disabled={@disabled}
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"
>
Update Entry
</button>
"""
end
# Callbacks
@impl Phoenix.LiveComponent
def update(assigns, socket) do
chart_data = Enum.map(assigns.history, &chart_point/1)
socket =
socket
|> assign(assigns)
|> assign_new(:changed?, fn -> false end)
|> push_event("cron-history", %{history: chart_data})
{:ok, socket}
end
defp chart_point(job) do
timestamp =
(job.finished_at || job.attempted_at || job.scheduled_at)
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_unix(:millisecond)
duration =
case {job.attempted_at, job.finished_at} do
{nil, _} ->
0
{attempted_at, nil} ->
NaiveDateTime.diff(NaiveDateTime.utc_now(), attempted_at, :millisecond)
{attempted_at, finished_at} ->
NaiveDateTime.diff(finished_at, attempted_at, :millisecond)
end
%{timestamp: timestamp, duration: duration, state: job.state}
end
# Handlers
@impl Phoenix.LiveComponent
def handle_event("run-now", _params, socket) do
enforce_access!(:insert_jobs, socket.assigns.access)
%{cron: cron, conf: conf} = socket.assigns
args = Map.get(cron.opts, "args", %{})
opts =
cron.opts
|> Map.take(~w(max_attempts priority queue tags))
|> Keyword.new(fn {key, val} -> {String.to_existing_atom(key), val} end)
|> Keyword.put(:worker, cron.worker)
|> Keyword.put(:meta, %{cron: true, cron_expr: cron.expression, cron_name: cron.name})
changeset = Job.new(args, opts)
case Oban.insert(conf.name, changeset) do
{:ok, _job} ->
send(self(), :refresh)
send(self(), {:flash, :info, "Job inserted for #{cron.worker}"})
{:error, _reason} ->
send(self(), {:flash, :error, "Failed to insert job"})
end
{:noreply, socket}
end
def handle_event("toggle-pause", _params, socket) do
enforce_access!(:pause_crons, socket.assigns.access)
%{cron: cron, conf: conf} = socket.assigns
paused? = not cron.paused?
DynamicCron.update(conf.name, cron.name, paused: paused?)
{:noreply, assign(socket, cron: %{cron | paused?: paused?})}
end
def handle_event("delete-cron", _params, socket) do
enforce_access!(:delete_crons, socket.assigns.access)
%{cron: cron, conf: conf, params: params} = socket.assigns
case DynamicCron.delete(conf.name, cron.name) do
{:ok, _deleted} ->
send(self(), {:flash, :info, "Deleted cron #{cron.name}"})
{:noreply, push_patch(socket, to: oban_path(:crons, params))}
{:error, _reason} ->
send(self(), {:flash, :error, "Failed to delete cron"})
{:noreply, socket}
end
end
def handle_event("form-change", params, socket) do
changed? =
params
|> parse_form_params(socket.assigns.cron)
|> Enum.any?(fn {_key, val} -> not is_nil(val) end)
{:noreply, assign(socket, changed?: changed?)}
end
def handle_event("keydown", %{"key" => "Escape"}, socket) do
{:noreply, push_patch(socket, to: oban_path(:crons, socket.assigns.params))}
end
def handle_event("keydown", _params, socket) do
{:noreply, socket}
end
def handle_event("save-cron", params, socket) do
enforce_access!(:update_crons, socket.assigns.access)
%{cron: cron, conf: conf} = socket.assigns
opts =
params
|> parse_form_params(cron)
|> Enum.reject(fn {_key, val} -> is_nil(val) end)
case DynamicCron.update(conf.name, cron.name, opts) do
{:ok, _entry} ->
send(self(), :refresh)
send(self(), {:flash, :info, "Cron configuration updated"})
{:noreply, assign(socket, changed?: false)}
{:error, _reason} ->
send(self(), {:flash, :error, "Failed to update cron configuration"})
{:noreply, socket}
end
end
# Helpers
defp parse_form_params(params, cron) do
[
name: new_val?(parse_string(params["name"]), cron.name),
worker: new_val?(parse_string(params["worker"]), cron.worker),
expression: new_val?(params["expression"], cron.expression),
queue: new_val?(parse_string(params["queue"]), get_opt(cron, "queue"), "default"),
timezone: new_val?(parse_string(params["timezone"]), get_opt(cron, "timezone"), "Etc/UTC"),
priority: new_val?(parse_int(params["priority"]), get_opt(cron, "priority")),
max_attempts: new_val?(parse_int(params["max_attempts"]), get_opt(cron, "max_attempts")),
guaranteed: new_val?(params["guaranteed"] == "true", get_opt(cron, "guaranteed") == true),
tags: new_val?(parse_tags(params["tags"]), get_opt(cron, "tags")),
args: new_val?(parse_json(params["args"]), get_opt(cron, "args"))
]
end
defp new_val?(new_val, current_val, default \\ nil)
defp new_val?(nil, _current, _default), do: nil
defp new_val?("", _current, _default), do: nil
defp new_val?(val, val, _default), do: nil
defp new_val?(val, nil, val), do: nil
defp new_val?(val, _current, _default), do: val
defp get_opt(%{opts: opts}, key) do
Map.get(opts, key)
end
defp format_tags(%{opts: %{"tags" => tags}}) when is_list(tags), do: Enum.join(tags, ", ")
defp format_tags(_), do: nil
defp format_args(%{opts: %{"args" => args}}) when is_map(args), do: Oban.JSON.encode!(args)
defp format_args(_), do: nil
defp state_label(nil), do: "Unknown"
defp state_label(state), do: String.capitalize(state)
end

View file

@ -0,0 +1,56 @@
defmodule Oban.Web.Crons.Helpers do
@moduledoc false
use Phoenix.Component
alias Oban.Web.Colors
alias Oban.Web.Components.Icons
attr :state, :string, required: true
attr :paused, :boolean, default: false
def state_icon(%{paused: true} = assigns) do
~H"""
<Icons.pause_circle class="w-5 h-5 text-gray-400" />
"""
end
def state_icon(assigns) do
assigns = assign(assigns, :color_class, Colors.state_text_class(assigns.state))
~H"""
<%= case @state do %>
<% "available" -> %>
<Icons.ellipsis_horizontal_circle class={["w-5 h-5", @color_class]} />
<% "cancelled" -> %>
<Icons.x_circle class={["w-5 h-5", @color_class]} />
<% "completed" -> %>
<Icons.check_circle class={["w-5 h-5", @color_class]} />
<% "discarded" -> %>
<Icons.exclamation_circle class={["w-5 h-5", @color_class]} />
<% "executing" -> %>
<Icons.play_circle class={["w-5 h-5", @color_class]} />
<% "retryable" -> %>
<Icons.arrow_path class={["w-5 h-5", @color_class]} />
<% "scheduled" -> %>
<Icons.clock class={["w-5 h-5", @color_class]} />
<% _ -> %>
<Icons.minus_circle class="w-5 h-5 text-gray-400" />
<% end %>
"""
end
@doc """
Converts a NaiveDateTime to unix milliseconds, returning empty string for nil.
"""
def maybe_to_unix(nil), do: ""
def maybe_to_unix(timestamp) do
timestamp
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_unix(:millisecond)
end
def show_name?(%{dynamic?: true, name: name, worker: worker}), do: name != worker
def show_name?(_cron), do: false
end

View file

@ -0,0 +1,332 @@
defmodule Oban.Web.Crons.NewComponent do
use Oban.Web, :live_component
import Oban.Web.FormComponents
alias Oban.Pro.Plugins.DynamicCron
alias Oban.Web.Timezones
@compile {:no_warn_undefined, DynamicCron}
@fields ~w(args expression max_attempts name priority queue tags timezone worker)a
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div
id="new-cron"
class="relative z-50 hidden"
phx-mounted={show_drawer()}
phx-remove={hide_drawer()}
phx-window-keydown="keydown"
phx-target={@myself}
>
<div
id="new-cron-bg"
class="bg-zinc-50/80 dark:bg-zinc-950/80 fixed inset-0 hidden transition-opacity"
aria-hidden="true"
phx-click="close"
phx-target={@myself}
/>
<div class="fixed inset-0 overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<div
id="new-cron-panel"
class="pointer-events-auto w-screen max-w-md hidden transition-transform translate-x-full"
>
<div class="flex h-full flex-col overflow-y-scroll bg-white dark:bg-gray-900 shadow-xl">
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
New Dynamic Cron
</h2>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer"
phx-click="close"
phx-target={@myself}
aria-label="Close"
>
<Icons.x_mark class="h-6 w-6" />
</button>
</div>
<form
id="new-cron-form"
class="flex-1 px-4 py-6 space-y-4"
phx-change="form-change"
phx-submit="create-cron"
phx-target={@myself}
>
<.form_field
label="Worker"
name="worker"
value={@form.worker}
placeholder="MyApp.Workers.SomeWorker"
required={true}
/>
<.form_field
label="Name"
name="name"
value={@form.name}
placeholder="some-worker"
required={true}
/>
<.form_field
label="Expression"
name="expression"
value={@form.expression}
placeholder="* * * * *"
required={true}
/>
<.select_field
label="Queue"
name="queue"
value={@form.queue}
options={queue_options(@queues)}
/>
<.select_field
label="Timezone"
name="timezone"
value={@form.timezone}
options={Timezones.options_with_blank()}
/>
<div class="grid grid-cols-2 gap-4">
<.form_field
label="Priority"
name="priority"
value={@form.priority}
type="number"
placeholder="0"
/>
<.form_field
label="Max Attempts"
name="max_attempts"
value={@form.max_attempts}
type="number"
placeholder="20"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<.form_field label="Tags" name="tags" value={@form.tags} placeholder="tag1, tag2" />
<div class="flex items-end pb-2">
<.checkbox_field
label="Guaranteed"
name="guaranteed"
checked={@form.guaranteed}
/>
</div>
</div>
<.form_field
label="Args"
name="args"
value={@form.args}
type="textarea"
placeholder="{}"
rows={1}
/>
<div class="pt-4">
<button
type="submit"
class="w-full px-4 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"
>
Create Cron
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
"""
end
# Callbacks
@impl Phoenix.LiveComponent
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:form, fn -> default_form() end)
{:ok, socket}
end
# Events
@impl Phoenix.LiveComponent
def handle_event("form-change", params, socket) do
form = socket.assigns.form
# Auto-generate name from worker if name is empty or matches previous auto-generated name
name =
if params["name"] == "" or params["name"] == form.name do
worker_to_name(params["worker"])
else
params["name"]
end
form =
@fields
|> Map.new(fn key -> {key, params[to_string(key)]} end)
|> Map.put(:name, name)
|> Map.put(:guaranteed, params["guaranteed"] == "true")
{:noreply, assign(socket, form: form)}
end
def handle_event("create-cron", params, socket) do
enforce_access!(:insert_crons, socket.assigns.access)
%{conf: conf} = socket.assigns
with {:ok, worker} <- parse_worker(params["worker"]),
{:ok, opts} <- build_opts(params) do
case DynamicCron.insert(conf.name, [{params["expression"], worker, opts}]) do
{:ok, _entries} ->
send(self(), {:flash, :info, "Cron '#{params["name"]}' created successfully"})
{:noreply, push_patch(socket, to: oban_path(:crons))}
{:error, reason} ->
send(self(), {:flash, :error, "Failed to create cron: #{inspect(reason)}"})
{:noreply, socket}
end
else
{:error, message} ->
send(self(), {:flash, :error, message})
{:noreply, socket}
end
end
def handle_event("close", _params, socket) do
{:noreply, push_patch(socket, to: oban_path(:crons))}
end
def handle_event("keydown", %{"key" => "Escape"}, socket) do
{:noreply, push_patch(socket, to: oban_path(:crons))}
end
def handle_event("keydown", _params, socket) do
{:noreply, socket}
end
# JS Commands
defp show_drawer do
%JS{}
|> JS.show(to: "#new-cron")
|> JS.show(
to: "#new-cron-bg",
transition: {"ease-out duration-300", "opacity-0", "opacity-100"}
)
|> JS.show(
to: "#new-cron-panel",
transition: {"ease-out duration-300", "translate-x-full", "translate-x-0"}
)
|> JS.add_class("overflow-hidden", to: "body")
end
defp hide_drawer do
%JS{}
|> JS.hide(
to: "#new-cron-bg",
transition: {"ease-in duration-200", "opacity-100", "opacity-0"}
)
|> JS.hide(
to: "#new-cron-panel",
time: 200,
transition: {"ease-in duration-200", "translate-x-0", "translate-x-full"}
)
|> JS.hide(to: "#new-cron", transition: {"block", "block", "hidden"})
|> JS.remove_class("overflow-hidden", to: "body")
end
# Helpers
defp default_form do
@fields
|> Map.new(&{&1, ""})
|> Map.put(:guaranteed, false)
end
defp worker_to_name(""), do: ""
defp worker_to_name(worker) do
worker
|> String.split(".")
|> List.last()
|> Macro.underscore()
|> String.replace("_", "-")
end
defp parse_worker(worker) when is_binary(worker) and worker != "" do
{:ok, String.trim(worker)}
end
defp parse_worker(_), do: {:error, "Worker is required"}
defp build_opts(params) do
opts = [name: params["name"]]
opts =
if params["guaranteed"] == "true" do
Keyword.put(opts, :guaranteed, true)
else
opts
end
opts =
if params["queue"] != "" do
Keyword.put(opts, :queue, params["queue"])
else
opts
end
opts =
if params["timezone"] != "" do
Keyword.put(opts, :timezone, params["timezone"])
else
opts
end
opts =
case parse_int(params["priority"]) do
nil -> opts
priority -> Keyword.put(opts, :priority, priority)
end
opts =
case parse_int(params["max_attempts"]) do
nil -> opts
max_attempts -> Keyword.put(opts, :max_attempts, max_attempts)
end
opts =
case parse_tags(params["tags"]) do
nil -> opts
tags -> Keyword.put(opts, :tags, tags)
end
opts =
case parse_json(params["args"]) do
nil -> opts
args -> Keyword.put(opts, :args, args)
end
{:ok, opts}
end
end

View file

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

View file

@ -0,0 +1,55 @@
defmodule Oban.Web.HelpComponent do
use Oban.Web, :live_component
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div class="relative" id="help-selector">
<button
aria-expanded="true"
aria-haspopup="listbox"
class="cursor-pointer text-gray-500 dark:text-gray-400 focus:outline-none hover:text-gray-700 dark:hover:text-gray-200 hidden md:block"
data-title="Help"
id="help-menu-toggle"
phx-hook="Tippy"
phx-click={JS.toggle(to: "#help-menu")}
type="button"
>
<Icons.question_mark_circle />
</button>
<ul
class="hidden absolute z-50 top-full right-0 mt-2 py-2 w-48 overflow-hidden rounded-md shadow-lg text-sm font-semibold bg-white dark:bg-gray-800 focus:outline-none"
id="help-menu"
role="listbox"
tabindex="-1"
>
<li
class="block w-full py-1 px-2 flex items-center cursor-pointer space-x-2 text-gray-500 dark:text-gray-400 hover:bg-gray-50 hover:dark:bg-gray-600/30"
phx-click-away={JS.hide(to: "#help-menu")}
role="option"
>
<a
href="https://hexdocs.pm/oban_web"
target="_blank"
rel="noopener noreferrer"
class="flex items-center space-x-2 w-full"
>
<Icons.arrow_top_right_on_square class="w-5 h-5" />
<span class="text-gray-800 dark:text-gray-200">Documentation</span>
</a>
</li>
<li
class="block w-full py-1 px-2 flex items-center cursor-pointer space-x-2 text-gray-500 dark:text-gray-400 hover:bg-gray-50 hover:dark:bg-gray-600/30"
phx-click-away={JS.hide(to: "#help-menu")}
phx-click={JS.hide(to: "#help-menu") |> JS.exec("data-shortcut", to: "#shortcuts")}
role="option"
>
<Icons.command_line class="w-5 h-5" />
<span class="text-gray-800 dark:text-gray-200">Keyboard shortcuts</span>
</li>
</ul>
</div>
"""
end
end

View file

@ -38,7 +38,7 @@ defmodule Oban.Web.InstancesComponent do
<button <button
aria-expanded="true" aria-expanded="true"
aria-haspopup="listbox" aria-haspopup="listbox"
class="rounded-md px-3 py-2 text-sm text-gray-600 dark:text-gray-300 class="cursor-pointer rounded-md px-3 py-2 text-sm text-gray-600 dark:text-gray-300
hover:text-gray-800 dark:hover:text-gray-200 hover:text-gray-800 dark:hover:text-gray-200
hover:bg-black/5 dark:hover:bg-white/5 hover:bg-black/5 dark:hover:bg-white/5
ring-1 ring-inset ring-gray-400 dark:ring-gray-700 ring-1 ring-inset ring-gray-400 dark:ring-gray-700
@ -53,7 +53,7 @@ defmodule Oban.Web.InstancesComponent do
</button> </button>
<ul <ul
class="hidden absolute z-10 top-full right-0 mt-2 text-sm font-semibold overflow-auto rounded-md bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black/5" class="hidden absolute z-10 top-full right-0 mt-2 py-2 text-sm font-semibold overflow-auto rounded-md bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black/5"
id="instance-select-menu" id="instance-select-menu"
> >
<li <li
@ -85,7 +85,7 @@ defmodule Oban.Web.InstancesComponent do
allowed = Resolver.call_with_fallback(resolver, :resolve_instances, [user]) allowed = Resolver.call_with_fallback(resolver, :resolve_instances, [user])
if allowed == :all or Enum.member?(allowed, name) do if allowed == :all or name in Enum.map(allowed, &inspect/1) do
send(self(), {:select_instance, name}) send(self(), {:select_instance, name})
end end

View file

@ -12,13 +12,15 @@ defmodule Oban.Web.Jobs.ChartComponent do
@impl Phoenix.LiveComponent @impl Phoenix.LiveComponent
def update(assigns, socket) do def update(assigns, socket) do
default_series = hd(series())
socket = socket =
socket socket
|> assign(conf: assigns.conf, params: assigns.params) |> assign(conf: assigns.conf, params: assigns.params)
|> assign_new(:group, fn -> init_lazy(:group, assigns, hd(groups())) end) |> assign_new(:group, fn -> init_lazy(:group, assigns, hd(groups())) end)
|> assign_new(:ntile, fn -> init_lazy(:ntile, assigns, "sum") end) |> assign_new(:ntile, fn -> init_lazy(:ntile, assigns, ntile_for_series(default_series)) end)
|> assign_new(:period, fn -> init_lazy(:period, assigns, hd(periods())) end) |> assign_new(:period, fn -> init_lazy(:period, assigns, hd(periods())) end)
|> assign_new(:series, fn -> init_lazy(:series, assigns, hd(series())) end) |> assign_new(:series, fn -> init_lazy(:series, assigns, default_series) end)
|> assign_new(:visible, fn -> init_lazy(:visible, assigns, true) end) |> assign_new(:visible, fn -> init_lazy(:visible, assigns, true) end)
socket = socket =
@ -45,7 +47,7 @@ defmodule Oban.Web.Jobs.ChartComponent do
@impl Phoenix.LiveComponent @impl Phoenix.LiveComponent
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="bg-white dark:bg-gray-900 rounded-md shadow-md w-full mb-3"> <div class="w-full bg-white dark:bg-gray-900 rounded-md shadow-md mb-3">
<div class="flex items-center justify-between p-3"> <div class="flex items-center justify-between p-3">
<div id="chart-h" class="flex items-center text-gray-900 dark:text-gray-200"> <div id="chart-h" class="flex items-center text-gray-900 dark:text-gray-200">
<button <button
@ -127,9 +129,12 @@ defmodule Oban.Web.Jobs.ChartComponent do
<div <div
id="chart" id="chart"
class={["w-full relative cursor-crosshair pl-5 pr-3 h-200px", unless(@visible, do: "hidden")]} class={[
"w-full relative cursor-crosshair pl-5 pr-3 h-200px",
unless(@visible, do: "hidden")
]}
> >
<canvas id="chart-canvas" phx-update="ignore" phx-hook="Charter"></canvas> <canvas id="chart-canvas" phx-update="ignore" phx-hook="JobsChart"></canvas>
</div> </div>
</div> </div>
""" """
@ -142,9 +147,11 @@ defmodule Oban.Web.Jobs.ChartComponent do
cols = assigns.max_cols cols = assigns.max_cols
sy_time = Timing.snap(System.system_time(:second), step) sy_time = Timing.snap(System.system_time(:second), step)
filters = params_to_filters(assigns.params)
opts = [ opts = [
by: step, by: step,
filters: params_to_filters(assigns.params), filters: filters,
group: assigns.group, group: assigns.group,
lookback: cols * step, lookback: cols * step,
operation: ntile_to_operation(assigns.ntile), operation: ntile_to_operation(assigns.ntile),
@ -247,6 +254,9 @@ defmodule Oban.Web.Jobs.ChartComponent do
defp groups_for_series("full_count"), do: ~w(state queue) defp groups_for_series("full_count"), do: ~w(state queue)
defp groups_for_series(_series), do: groups() defp groups_for_series(_series), do: groups()
defp ntile_for_series(series) when series in ~w(exec_time wait_time), do: "p95"
defp ntile_for_series(_series), do: "sum"
defp metric_label("exec_count"), do: "Executed Count" defp metric_label("exec_count"), do: "Executed Count"
defp metric_label("full_count"), do: "Full Count" defp metric_label("full_count"), do: "Full Count"
defp metric_label("exec_time"), do: "Execution Time" defp metric_label("exec_time"), do: "Execution Time"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,106 @@
defmodule Oban.Web.Jobs.HistoryChartComponent do
use Oban.Web, :live_component
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div>
<button
id="history-toggle"
type="button"
class="flex items-center w-full space-x-2 px-2 py-1.5 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
phx-click={toggle_section()}
>
<Icons.chevron_right id="history-chevron" class="w-5 h-5 transition-transform rotate-90" />
<span class="font-semibold">Recent Executions</span>
</button>
<div id="history-content" class="group relative mt-3">
<div
id="job-history-chart"
class="h-48 bg-gray-50 dark:bg-gray-800 rounded-md p-4"
phx-hook="JobHistoryChart"
phx-update="ignore"
data-current-job-id={@job.id}
>
</div>
<.link
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" />
</.link>
</div>
</div>
"""
end
defp toggle_section do
%JS{}
|> JS.toggle(to: "#history-content", in: "fade-in-scale", out: "fade-out-scale")
|> JS.add_class("rotate-90", to: "#history-chevron:not(.rotate-90)")
|> JS.remove_class("rotate-90", to: "#history-chevron.rotate-90")
end
defp all_jobs_path(job) do
worker = Map.get(job.meta, "worker", job.worker)
oban_path(:jobs, %{workers: [worker], state: "completed"})
end
@impl Phoenix.LiveComponent
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> push_chart_data()
{:ok, socket}
end
defp push_chart_data(socket) do
%{job: job, history: history} = socket.assigns
chart_data =
Enum.map(history, fn hist_job ->
%{
id: hist_job.id,
timestamp: timestamp_for(hist_job),
wait_time: wait_time_for(hist_job),
exec_time: exec_time_for(hist_job),
state: hist_job.state,
current: hist_job.id == job.id
}
end)
push_event(socket, "job-history", %{history: chart_data})
end
defp timestamp_for(job) do
datetime =
job.completed_at || job.cancelled_at || job.discarded_at || job.attempted_at ||
job.scheduled_at
datetime
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_unix(:millisecond)
end
defp exec_time_for(job) do
case {job.attempted_at, finished_at(job)} do
{nil, _} -> 0
{_, nil} -> 0
{attempted_at, finished_at} -> NaiveDateTime.diff(finished_at, attempted_at, :millisecond)
end
end
defp wait_time_for(job) do
case job.attempted_at do
nil -> 0
attempted_at -> NaiveDateTime.diff(attempted_at, job.scheduled_at, :millisecond)
end
end
defp finished_at(job) do
job.completed_at || job.cancelled_at || job.discarded_at
end
end

View file

@ -0,0 +1,290 @@
defmodule Oban.Web.Jobs.NewComponent do
use Oban.Web, :live_component
import Oban.Web.FormComponents
@fields ~w(args max_attempts priority queue scheduled_at tags worker)a
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div
id="new-job"
class="relative z-50 hidden"
phx-mounted={show_drawer()}
phx-remove={hide_drawer()}
phx-window-keydown="keydown"
phx-target={@myself}
>
<div
id="new-job-bg"
class="bg-zinc-50/80 dark:bg-zinc-950/80 fixed inset-0 hidden transition-opacity"
aria-hidden="true"
phx-click="close"
phx-target={@myself}
/>
<div class="fixed inset-0 overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<div
id="new-job-panel"
class="pointer-events-auto w-screen max-w-md hidden transition-transform translate-x-full"
>
<div class="flex h-full flex-col overflow-y-scroll bg-white dark:bg-gray-900 shadow-xl">
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
New Job
</h2>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer"
phx-click="close"
phx-target={@myself}
aria-label="Close"
>
<Icons.x_mark class="h-6 w-6" />
</button>
</div>
<form
id="new-job-form"
class="flex-1 px-4 py-6 space-y-4"
phx-change="form-change"
phx-submit="create-job"
phx-target={@myself}
>
<.form_field
label="Worker"
name="worker"
value={@form.worker}
placeholder="MyApp.Workers.SomeWorker"
required={true}
/>
<.form_field
label="Args"
name="args"
value={@form.args}
type="textarea"
placeholder="{}"
required={true}
rows={3}
/>
<.select_field
label="Queue"
name="queue"
value={@form.queue}
options={queue_options(@queues)}
/>
<div class="grid grid-cols-2 gap-4">
<.form_field
label="Priority"
name="priority"
value={@form.priority}
type="number"
placeholder="0"
hint="0 (highest) to 9 (lowest)"
/>
<.form_field
label="Max Attempts"
name="max_attempts"
value={@form.max_attempts}
type="number"
placeholder="20"
/>
</div>
<.form_field
label="Scheduled At"
name="scheduled_at"
value={@form.scheduled_at}
type="datetime-local"
hint="Leave empty to run immediately"
/>
<.form_field label="Tags" name="tags" value={@form.tags} placeholder="tag1, tag2" />
<div class="pt-4">
<button
type="submit"
class="w-full px-4 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"
>
Create Job
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
"""
end
@impl Phoenix.LiveComponent
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:form, fn -> default_form() end)
{:ok, socket}
end
@impl Phoenix.LiveComponent
def handle_event("form-change", params, socket) do
form = Map.new(@fields, fn key -> {key, params[to_string(key)]} end)
{:noreply, assign(socket, form: form)}
end
def handle_event("create-job", params, socket) do
enforce_access!(:insert_jobs, socket.assigns.access)
%{conf: conf} = socket.assigns
with {:ok, worker} <- parse_worker(params["worker"]),
{:ok, args} <- parse_args(params["args"]),
{:ok, opts} <- build_opts(params) do
changeset = Oban.Job.new(args, [{:worker, worker} | opts])
case Oban.insert(conf.name, changeset) do
{:ok, job} ->
send(self(), {:flash, :info, "Job created successfully"})
{:noreply, push_patch(socket, to: oban_path([:jobs, job.id]))}
{:error, changeset} ->
message = format_changeset_error(changeset)
send(self(), {:flash, :error, "Failed to create job: #{message}"})
{:noreply, socket}
end
else
{:error, message} ->
send(self(), {:flash, :error, message})
{:noreply, socket}
end
end
def handle_event("close", _params, socket) do
{:noreply, push_patch(socket, to: oban_path(:jobs))}
end
def handle_event("keydown", %{"key" => "Escape"}, socket) do
{:noreply, push_patch(socket, to: oban_path(:jobs))}
end
def handle_event("keydown", _params, socket) do
{:noreply, socket}
end
# JS Commands
defp show_drawer do
%JS{}
|> JS.show(to: "#new-job")
|> JS.show(
to: "#new-job-bg",
transition: {"ease-out duration-300", "opacity-0", "opacity-100"}
)
|> JS.show(
to: "#new-job-panel",
transition: {"ease-out duration-300", "translate-x-full", "translate-x-0"}
)
|> JS.add_class("overflow-hidden", to: "body")
end
defp hide_drawer do
%JS{}
|> JS.hide(
to: "#new-job-bg",
transition: {"ease-in duration-200", "opacity-100", "opacity-0"}
)
|> JS.hide(
to: "#new-job-panel",
time: 200,
transition: {"ease-in duration-200", "translate-x-0", "translate-x-full"}
)
|> JS.hide(to: "#new-job", transition: {"block", "block", "hidden"})
|> JS.remove_class("overflow-hidden", to: "body")
end
# Helpers
defp default_form do
@fields
|> Map.new(&{&1, ""})
|> Map.put(:args, "{}")
end
defp parse_worker(worker) when is_binary(worker) and worker != "" do
{:ok, String.trim(worker)}
end
defp parse_worker(_), do: {:error, "Worker is required"}
defp parse_args(args) when is_binary(args) do
case parse_json(args) do
nil -> {:error, "Args must be a valid JSON object"}
map -> {:ok, map}
end
end
defp parse_args(_), do: {:error, "Args must be a valid JSON object"}
defp build_opts(params) do
queue = if params["queue"] in [nil, ""], do: "default", else: params["queue"]
opts = [queue: queue]
opts =
case parse_int(params["priority"]) do
nil -> opts
priority when priority >= 0 and priority <= 9 -> Keyword.put(opts, :priority, priority)
_ -> opts
end
opts =
case parse_int(params["max_attempts"]) do
nil -> opts
max_attempts -> Keyword.put(opts, :max_attempts, max_attempts)
end
opts =
case parse_scheduled_at(params["scheduled_at"]) do
nil -> opts
scheduled_at -> Keyword.put(opts, :scheduled_at, scheduled_at)
end
opts =
case parse_tags(params["tags"]) do
nil -> opts
tags -> Keyword.put(opts, :tags, tags)
end
{:ok, opts}
end
defp parse_scheduled_at(nil), do: nil
defp parse_scheduled_at(""), do: nil
defp parse_scheduled_at(str) when is_binary(str) do
case DateTime.from_iso8601(str <> ":00Z") do
{:ok, datetime} -> datetime
_ -> nil
end
end
defp format_changeset_error(changeset) do
changeset
|> Ecto.Changeset.traverse_errors(fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
|> Enum.map_join(", ", fn {key, errors} -> "#{key} #{Enum.join(errors, ", ")}" end)
end
end

View file

@ -42,42 +42,7 @@ defmodule Oban.Web.Jobs.SidebarComponent do
active={active_filter?(@params, :queues, queue.name)} active={active_filter?(@params, :queues, queue.name)}
patch={patch_params(@params, :jobs, :queues, queue.name)} patch={patch_params(@params, :jobs, :queues, queue.name)}
values={[Queue.total_limit(queue), queue.counts.executing, queue.counts.available]} values={[Queue.total_limit(queue), queue.counts.executing, queue.counts.available]}
> />
<:statuses>
<Icons.arrow_trending_down
:if={Queue.rate_limit?(queue)}
class="w-4 h-4"
data-title="Rate limited"
id={"#{queue.name}-is-rate-limited"}
phx-hook="Tippy"
rel="is-rate-limited"
/>
<Icons.globe
:if={Queue.global_limit?(queue)}
class="w-4 h-4"
data-title="Globally limited"
id={"#{queue.name}-is-global"}
phx-hook="Tippy"
rel="is-global"
/>
<Icons.pause_circle
:if={Queue.all_paused?(queue)}
class="w-4 h-4"
data-title="All paused"
id={"#{queue.name}-is-paused"}
phx-hook="Tippy"
rel="is-paused"
/>
<Icons.play_pause_circle
:if={Queue.any_paused?(queue) and not Queue.all_paused?(queue)}
class="w-4 h-4"
data-title="Some paused"
id={"#{queue.name}-is-some-paused"}
phx-hook="Tippy"
rel="has-some-paused"
/>
</:statuses>
</SidebarComponents.filter_row>
</SidebarComponents.section> </SidebarComponents.section>
</SidebarComponents.sidebar> </SidebarComponents.sidebar>
""" """

View file

@ -41,7 +41,7 @@ defmodule Oban.Web.Jobs.TableComponent do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div id="jobs-table" class="min-w-full"> <div id="jobs-table" class="min-w-full">
<ul class="flex items-center border-b border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-600"> <ul class="flex items-center border-b border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500">
<.header label="details" class="ml-12" /> <.header label="details" class="ml-12" />
<.header label="queue" class="ml-auto text-right" /> <.header label="queue" class="ml-auto text-right" />
<.header label="time" class="w-20 pr-3 text-right" /> <.header label="time" class="w-20 pr-3 text-right" />
@ -91,10 +91,14 @@ defmodule Oban.Web.Jobs.TableComponent do
myself={@myself} myself={@myself}
/> />
<.link patch={oban_path([:jobs, @job.id])} class="flex flex-grow items-center"> <.link
patch={oban_path([:jobs, @job.id])}
phx-click={JS.dispatch("phx:scroll-top", to: "body")}
class="flex flex-grow items-center"
>
<div class="py-2.5"> <div class="py-2.5">
<span class="block font-semibold text-sm text-gray-700 dark:text-gray-300" rel="worker"> <span class="block font-semibold text-sm text-gray-700 dark:text-gray-300" rel="worker">
{@job.worker} {Map.get(@job.meta, "decorated_name", @job.worker)}
</span> </span>
<span class="tabular text-xs text-gray-600 dark:text-gray-300" rel="attempts"> <span class="tabular text-xs text-gray-600 dark:text-gray-300" rel="attempts">
@ -112,10 +116,10 @@ defmodule Oban.Web.Jobs.TableComponent do
class="h-5 w-5 text-gray-500 dark:text-gray-300" class="h-5 w-5 text-gray-500 dark:text-gray-300"
id={"job-rescued-#{assigns.job.id}"} id={"job-rescued-#{assigns.job.id}"}
phx-hook="Tippy" phx-hook="Tippy"
data-title="Rescued by the DynamicLifeline plugin" data-title="Rescued by lifeline"
/> />
<Icons.crossbones_circle <Icons.crossbones_circle_solid
:if={orphaned?(@job, @producers)} :if={orphaned?(@job, @producers)}
class="h-5 w-5 text-gray-500 dark:text-gray-300" class="h-5 w-5 text-gray-500 dark:text-gray-300"
id={"job-orphaned-#{assigns.job.id}"} id={"job-orphaned-#{assigns.job.id}"}
@ -197,14 +201,22 @@ defmodule Oban.Web.Jobs.TableComponent do
defp timestamp(job) do defp timestamp(job) do
datetime = datetime =
case job.state do case job do
"available" -> job.scheduled_at %{state: state, scheduled_at: at}
"cancelled" -> job.cancelled_at when state in ~w(available scheduled suspended retryable) ->
"completed" -> job.completed_at at
"discarded" -> job.discarded_at
"executing" -> job.attempted_at %{state: "executing", attempted_at: at} ->
"retryable" -> job.scheduled_at at
"scheduled" -> job.scheduled_at
%{state: "cancelled", cancelled_at: at} ->
at
%{state: "completed", completed_at: at} ->
at
%{state: "discarded", discarded_at: at} ->
at
end end
if is_struct(datetime) do if is_struct(datetime) do
@ -230,5 +242,5 @@ defmodule Oban.Web.Jobs.TableComponent do
""" """
end end
defp loader_class(_), do: "text-gray-400 dark:text-gray-600 cursor-not-allowed" defp loader_class(_), do: "text-gray-400 dark:text-gray-500 cursor-not-allowed"
end end

View file

@ -3,147 +3,253 @@ defmodule Oban.Web.Jobs.TimelineComponent do
use Phoenix.Component use Phoenix.Component
alias Oban.Web.Colors
alias Oban.Web.Components.Icons alias Oban.Web.Components.Icons
alias Oban.Web.Timing alias Oban.Web.Timing
@empty_time ""
@state_to_timestamp %{
"cancelled" => :cancelled_at,
"discarded" => :discarded_at,
"executing" => :attempted_at,
"inserted" => :inserted_at,
"scheduled" => :scheduled_at
}
def render(assigns) do def render(assigns) do
assigns =
assigns
|> assign(:path, compute_path(assigns.job))
|> assign(:now, DateTime.from_unix!(assigns.os_time))
~H""" ~H"""
<div <div
id={"timeline-for-#{@state}"} id="job-timeline"
class="w-1/4 flex flex-col" class="w-full"
data-title={timestamp_title(@state, @job)} phx-hook="TimelineConnectors"
phx-hook="Tippy" data-entry-suspended={to_string(@path.entry == "suspended")}
data-entry-scheduled={to_string(@path.entry == "scheduled")}
data-entry-retryable={to_string(@path.entry == "retryable")}
data-engaged={to_string(@path.engaged)}
data-terminal-completed={to_string(@path.terminal == "completed")}
data-terminal-cancelled={to_string(@path.terminal == "cancelled")}
data-terminal-discarded={to_string(@path.terminal == "discarded")}
> >
<span class={"flex self-center justify-center items-center h-16 w-16 transition-colors duration-200 rounded-full #{timeline_class(@state, @job)}"}> <div id="timeline-boxes" class="relative">
<%= if timeline_icon(@state, @job) == :checkmark do %> <svg id="timeline-connectors" class="absolute inset-0 overflow-visible pointer-events-none" />
<Icons.check class="w-12 h-12" />
<% end %> <div class="relative grid grid-cols-4 gap-x-2 sm:gap-x-4 lg:gap-x-8 gap-y-3 sm:gap-y-5">
<%= if timeline_icon(@state, @job) == :spinner do %> <.state_box state="suspended" job={@job} path={@path} now={@now} />
<svg class="h-12 w-12 animate-spin" fill="currentColor" viewBox="0 0 20 20"> <div></div>
<path <div></div>
d="M10 1a.9.9 0 110 1.8 7.2 7.2 0 107.2 7.2.9.9 0 111.8 0 9 9 0 11-9-9z" <.state_box state="completed" job={@job} path={@path} now={@now} />
fill-rule="nonzero"
/> <.state_box state="scheduled" job={@job} path={@path} now={@now} />
</svg> <.state_box state="available" job={@job} path={@path} now={@now} />
<% end %> <.state_box state="executing" job={@job} path={@path} now={@now} />
</span> <.state_box state="cancelled" job={@job} path={@path} now={@now} />
<span class="block text-sm text-center font-semibold mt-2">
{timestamp_name(@state, @job)} <.state_box state="retryable" job={@job} path={@path} now={@now} />
</span> <div></div>
<span class="block text-sm text-center tabular"> <div></div>
{timeline_time(@state, @job, @os_time)} <.state_box state="discarded" job={@job} path={@path} now={@now} />
</div>
</div>
</div>
"""
end
attr :state, :string, required: true
attr :job, :map, required: true
attr :path, :map, required: true
attr :now, :any, required: true
defp state_box(assigns) do
status = box_status(assigns.state, assigns.job, assigns.path)
{border_class, bg_class, text_color} = colors_for_box(assigns.state, status)
icon = icon_for_box(status, assigns.state)
dim_class = if status == :completed, do: "saturate-40", else: ""
assigns =
assigns
|> assign(:icon, icon)
|> assign(:border_class, border_class)
|> assign(:bg_class, bg_class)
|> assign(:text_color, text_color)
|> assign(:dim_class, dim_class)
|> assign(:timestamp, format_timestamp(assigns.state, assigns.job, assigns.now))
|> assign(:tooltip, timestamp_title(assigns.state, assigns.job))
~H"""
<div
class={"flex flex-col sm:flex-row items-center sm:justify-between h-12 sm:h-12 rounded-lg border-2 px-2 sm:px-3 #{@border_class} #{@bg_class} #{@dim_class}"}
data-title={@tooltip}
phx-hook="Tippy"
id={"timeline-#{@state}"}
>
<div class="flex items-center gap-1 sm:gap-2">
<span class={"flex items-center justify-center w-5 h-5 #{@text_color}"}>
<.state_icon icon={@icon} />
</span>
<span class={"hidden sm:inline text-sm font-semibold capitalize #{@text_color}"}>
{@state}
</span>
</div>
<span class={"hidden sm:inline text-xs tabular-nums #{@text_color}"}>
{@timestamp || ""}
</span> </span>
</div> </div>
""" """
end end
# Helpers defp icon_for_box(:inactive, _state), do: :pending
defp icon_for_box(:active, state) when state in ~w(completed cancelled discarded), do: :done
defp icon_for_box(:active, _state), do: :executing
defp icon_for_box(:completed, _state), do: :done
attr :icon, :atom, required: true
defp state_icon(%{icon: :pending} = assigns) do
~H"""
<Icons.ellipsis_horizontal_circle class="w-5 h-5" />
"""
end
defp state_icon(%{icon: :executing} = assigns) do
~H"""
<Icons.spinner class="w-5 h-5 animate-spin" />
"""
end
defp state_icon(%{icon: :done} = assigns) do
~H"""
<Icons.check_circle class="w-5 h-5" />
"""
end
# Path computation
defp compute_path(job) do
%{
entry: compute_entry_state(job),
terminal: compute_terminal_state(job),
engaged: not is_nil(job.attempted_at)
}
end
defp compute_entry_state(job) do
snoozed = Map.get(job.meta, "snoozed", 0)
cond do
job.state == "retryable" -> "retryable"
job.state == "suspended" -> "suspended"
job.attempt > 1 and snoozed < job.attempt -> "retryable"
true -> "scheduled"
end
end
defp compute_terminal_state(job) do
case job.state do
"completed" -> "completed"
"cancelled" -> "cancelled"
"discarded" -> "discarded"
_ -> nil
end
end
# Box status
defp box_status(state, job, path) do
cond do
state == job.state -> :active
state_completed?(state, job, path) -> :completed
true -> :inactive
end
end
defp state_completed?("suspended", job, path) do
path.entry == "suspended" and job.state != "suspended"
end
defp state_completed?("scheduled", job, path) do
path.entry in ["scheduled", "suspended"] and job.state not in ["scheduled", "suspended"]
end
defp state_completed?("retryable", _job, path) do
path.entry == "retryable"
end
defp state_completed?("available", job, _path) do
not is_nil(job.attempted_at) and job.state not in ~w(available scheduled suspended retryable)
end
defp state_completed?("executing", job, _path) do
job.state in ["completed", "cancelled", "discarded"] and not is_nil(job.attempted_at)
end
defp state_completed?("completed", job, _path), do: job.state == "completed"
defp state_completed?("cancelled", job, _path), do: job.state == "cancelled"
defp state_completed?("discarded", job, _path), do: job.state == "discarded"
defp colors_for_box(_state, :inactive), do: Colors.inactive_classes()
defp colors_for_box(state, _status), do: Colors.state_classes(state)
# Timestamp formatting
@state_labels %{
"suspended" => "Inserted At",
"scheduled" => "Scheduled At",
"retryable" => "Retrying At",
"available" => "Started At",
"executing" => "Attempted At",
"completed" => "Completed At",
"cancelled" => "Cancelled At",
"discarded" => "Discarded At"
}
defp format_timestamp("executing", job, now) do
cond do
job.state == "executing" and job.attempted_at ->
job.attempted_at |> DateTime.diff(now) |> Timing.to_duration()
job.state in ~w(completed cancelled discarded) and job.attempted_at ->
job.attempted_at |> DateTime.diff(now) |> Timing.to_words()
true ->
nil
end
end
defp format_timestamp(state, job, now) do
state
|> timestamp_for_state(job)
|> format_time(now)
end
defp format_time(nil, _now), do: nil
defp format_time(timestamp, now) do
timestamp
|> DateTime.diff(now)
|> Timing.to_words()
end
defp timestamp_for_state("suspended", job) do
if job.state == "suspended", do: job.inserted_at
end
defp timestamp_for_state("scheduled", job) do
if job.attempt < 2 and job.state not in ~w(suspended retryable), do: job.scheduled_at
end
defp timestamp_for_state("retryable", job) do
if job.state == "retryable" or job.attempt > 1, do: job.scheduled_at
end
defp timestamp_for_state("available", job) do
if job.state != "retryable", do: job.attempted_at
end
defp timestamp_for_state("executing", job), do: job.attempted_at
defp timestamp_for_state("completed", job), do: job.completed_at
defp timestamp_for_state("cancelled", job), do: job.cancelled_at
defp timestamp_for_state("discarded", job), do: job.discarded_at
defp timestamp_title(state, job) do defp timestamp_title(state, job) do
case state do "#{@state_labels[state]}: #{truncate_sec(timestamp_for_state(state, job))}"
"inserted" -> "Inserted At: #{truncate_sec(job.inserted_at)}"
"scheduled" -> "Scheduled At: #{truncate_sec(job.scheduled_at)}"
"executing" -> "Attempted At: #{truncate_sec(job.attempted_at)}"
"completed" -> "Completed At: #{truncate_sec(job.completed_at)}"
"cancelled" -> "Cancelled At: #{truncate_sec(job.cancelled_at)}"
"discarded" -> "Discarded At: #{truncate_sec(job.discarded_at)}"
end
end end
defp timeline_class(state, job) do defp truncate_sec(nil), do: ""
case absolute_state(state, job) do defp truncate_sec(datetime), do: DateTime.truncate(datetime, :second)
:finished -> "bg-green-500 text-white"
:retrying -> "bg-orange-400 text-white"
:started -> "bg-yellow-400 text-white"
:unstarted -> "bg-gray-100 text-white dark:bg-black dark:bg-opacity-25"
end
end
defp timeline_icon(state, job) do
case absolute_state(state, job) do
:finished -> :checkmark
:retrying -> :spinner
:started -> :spinner
:unstarted -> nil
end
end
defp timestamp_name(state, job) do
case {state, job.state} do
{"retryable", "retryable"} -> "Retryable"
{"retryable", _} -> "Scheduled"
{"executing", "executing"} -> "Executing"
{"executing", _} -> "Completed"
_ -> String.capitalize(state)
end
end
defp timeline_time(state, job, os_time) do
for_state = Map.get(@state_to_timestamp, state)
timestamp = Map.get(job, for_state)
now = DateTime.from_unix!(os_time)
case {state, job.state, timestamp} do
{_, _, nil} ->
@empty_time
{"executing", "executing", at} ->
at
|> DateTime.diff(now)
|> Timing.to_duration()
{"executing", "completed", _at} ->
words =
job.completed_at
|> DateTime.diff(now)
|> Timing.to_words()
duration =
job.attempted_at
|> DateTime.diff(job.completed_at)
|> Timing.to_duration()
"#{words} (#{duration})"
{"executing", _, _} ->
@empty_time
{_, _, at} ->
at
|> DateTime.diff(now)
|> Timing.to_words()
end
end
defp absolute_state(state, job) do
for_state = Map.get(@state_to_timestamp, state)
timestamp = Map.get(job, for_state)
absolute_state(state, job.state, timestamp)
end
defp absolute_state("executing", "completed", _), do: :finished
defp absolute_state("executing", "executing", _), do: :started
defp absolute_state("executing", _, _), do: :unstarted
defp absolute_state("cancelled", "cancelled", _), do: :finished
defp absolute_state("cancelled", "retryable", _), do: :unstarted
defp absolute_state("discarded", "discarded", _), do: :finished
defp absolute_state("discarded", "retryable", _), do: :unstarted
defp absolute_state("scheduled", "retryable", _), do: :retrying
defp absolute_state(state, state, _), do: :started
defp absolute_state(_, _, at) when not is_nil(at), do: :finished
defp absolute_state(_, _, _), do: :unstarted
defp truncate_sec(nil), do: @empty_time
defp truncate_sec(datetime), do: NaiveDateTime.truncate(datetime, :second)
end end

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
defmodule Oban.Web.Queues.DetailInsanceComponent do defmodule Oban.Web.Queues.DetailInstanceComponent do
use Oban.Web, :live_component use Oban.Web, :live_component
import Oban.Web.Helpers.QueueHelper import Oban.Web.Helpers.QueueHelper
@ -14,9 +14,11 @@ defmodule Oban.Web.Queues.DetailInsanceComponent do
socket = socket =
socket socket
|> assign(access: assigns.access, checks: assigns.checks) |> assign(access: assigns.access, checks: assigns.checks)
|> assign_new(:queue, fn -> assigns.checks["queue"] end) |> assign(node_history: assigns[:node_history] || [])
|> assign_new(:paused, fn -> assigns.checks["paused"] end) |> assign(queue: assigns.checks["queue"])
|> assign(paused: assigns.checks["paused"])
|> assign_new(:local_limit, fn -> assigns.checks["local_limit"] end) |> assign_new(:local_limit, fn -> assigns.checks["local_limit"] end)
|> assign_new(:editing?, fn -> false end)
{:ok, socket} {:ok, socket}
end end
@ -24,74 +26,153 @@ defmodule Oban.Web.Queues.DetailInsanceComponent do
@impl Phoenix.LiveComponent @impl Phoenix.LiveComponent
def render(assigns) do def render(assigns) do
~H""" ~H"""
<tr> <tr class={if @editing?, do: "bg-gray-50 dark:bg-gray-800"}>
<td class="pl-3 py-3">{node_name(@checks)}</td> <td class="pl-3 py-3">
<td class="text-right py-3">{executing_count(@checks)}</td> <div class="flex items-center space-x-2">
<td class="text-right py-3">{started_at(@checks)}</td> <span class="truncate">{node_name(@checks)}</span>
<td class="pl-9 py-3"> <.status_indicators checks={@checks} paused={@paused} />
<.pause_button </div>
disabled={not can?(:pause_queues, @access)}
node={node_name(@checks)}
paused={@paused}
queue={@queue}
target={@myself}
/>
</td> </td>
<td class="pr-3 py-3"> <%= if @editing? do %>
<form <td colspan="5" class="pr-3 py-3">
id={"#{@checks["node"]}-form"} <form
class="flex space-x-3" id={"#{@checks["node"]}-form"}
phx-target={@myself} class="flex items-center justify-end"
phx-submit="update" phx-target={@myself}
> phx-submit="update"
<input type="hidden" name="node" value={@checks["node"]} />
<Core.number_input
label={false}
name="local_limit"
value={@local_limit}
disabled={not can?(:scale_queues, @access)}
myself={@myself}
/>
<button
class={"block px-3 py-2 font-medium text-sm text-gray-600 dark:text-gray-100 bg-gray-300 dark:bg-blue-950 dark:bg-opacity-25 hover:bg-blue-500 hover:text-white dark:hover:bg-blue-500 dark:hover:text-white rounded-md shadow-sm #{if @local_limit == @checks["local_limit"], do: "opacity-30 pointer-events-none"}"}
type="submit"
> >
Scale <input type="hidden" name="node" value={@checks["node"]} />
</button>
</form> <div class="flex items-center space-x-4">
</td> <div class="w-24">
<input
type="number"
name="local_limit"
value={@local_limit}
disabled={not can?(:scale_queues, @access)}
class="block w-full font-mono text-sm shadow-sm border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-md focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
/>
</div>
<div class="flex space-x-2">
<button
type="button"
class="px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md cursor-pointer"
phx-click="cancel-edit"
phx-target={@myself}
>
Cancel
</button>
<button
type="submit"
class={"px-3 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 rounded-md cursor-pointer #{if @local_limit == @checks["local_limit"], do: "opacity-50 cursor-not-allowed"}"}
disabled={@local_limit == @checks["local_limit"]}
>
Scale
</button>
</div>
</div>
</form>
</td>
<% else %>
<td class="py-3">
<Core.sparkline
id={"sparkline-#{@checks["node"]}"}
history={if is_map(@node_history), do: @node_history, else: %{}}
count={120}
/>
</td>
<td class="text-right py-3 tabular">{executing_count(@checks)}</td>
<td class="text-right py-3 tabular">{@checks["local_limit"]}</td>
<td class="text-right py-3">{started_at(@checks)}</td>
<td class="pr-3 py-3">
<div class="flex items-center justify-end space-x-2">
<.pause_button
disabled={not can?(:pause_queues, @access)}
node={node_name(@checks)}
paused={@paused}
queue={@queue}
target={@myself}
/>
<.edit_button
disabled={not can?(:scale_queues, @access)}
node={@checks["node"]}
target={@myself}
/>
</div>
</td>
<% end %>
</tr> </tr>
""" """
end end
defp status_indicators(assigns) do
terminating? = not is_nil(assigns.checks["shutdown_started_at"])
assigns = assign(assigns, terminating?: terminating?)
~H"""
<span
: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
</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
</span>
"""
end
defp pause_button(assigns) do defp pause_button(assigns) do
assigns = assigns =
assigns assigns
|> assign_new(:id, fn -> "#{assigns.queue}-toggle-pause" end) |> assign_new(:id, fn -> "#{assigns.node}-toggle-pause" end)
|> assign_new(:title, fn -> if assigns.paused, do: "Resume queue", else: "Pause queue" end) |> assign_new(:tooltip, fn -> if assigns.paused, do: "Resume queue", else: "Pause queue" end)
~H""" ~H"""
<button <button
id={@id}
rel="toggle-pause" rel="toggle-pause"
class="block text-gray-400 dark:text-gray-600 hover:text-blue-500 dark:hover:text-blue-500" class="p-1.5 rounded text-gray-400 dark:text-gray-500 hover:text-yellow-500 dark:hover:text-yellow-500 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
data-title={@tooltip}
disabled={@disabled} disabled={@disabled}
phx-click="toggle-pause" phx-click="toggle-pause"
phx-hook="Tippy"
phx-target={@target} phx-target={@target}
phx-value-queue={@queue} phx-value-queue={@queue}
phx-value-node={@node} phx-value-node={@node}
type="button" type="button"
> >
<%= if @paused do %> <%= if @paused do %>
<Icons.play_circle_solid class="w-5 h-5" /> <Icons.play_circle class="w-5 h-5" />
<% else %> <% else %>
<Icons.pause_circle_solid class="w-5 h-5" /> <Icons.pause_circle class="w-5 h-5" />
<% end %> <% end %>
</button> </button>
""" """
end end
defp edit_button(assigns) do
~H"""
<button
id={"#{@node}-edit"}
rel="toggle-edit"
class="p-1.5 rounded text-gray-400 dark:text-gray-500 hover:text-violet-500 dark:hover:text-violet-500 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
data-title="Edit local limit"
disabled={@disabled}
phx-click="toggle-edit"
phx-hook="Tippy"
phx-target={@target}
type="button"
>
<Icons.pencil_square class="w-5 h-5" />
</button>
"""
end
@impl Phoenix.LiveComponent @impl Phoenix.LiveComponent
def handle_event("toggle-pause", _params, socket) do def handle_event("toggle-pause", _params, socket) do
enforce_access!(:pause_queues, socket.assigns.access) enforce_access!(:pause_queues, socket.assigns.access)
@ -104,6 +185,14 @@ defmodule Oban.Web.Queues.DetailInsanceComponent do
{:noreply, assign(socket, paused: not socket.assigns.paused)} {:noreply, assign(socket, paused: not socket.assigns.paused)}
end end
def handle_event("toggle-edit", _params, socket) do
{:noreply, assign(socket, editing?: not socket.assigns.editing?)}
end
def handle_event("cancel-edit", _params, socket) do
{:noreply, assign(socket, editing?: false, local_limit: socket.assigns.checks["local_limit"])}
end
def handle_event("update", %{"local_limit" => limit}, socket) do def handle_event("update", %{"local_limit" => limit}, socket) do
enforce_access!(:scale_queues, socket.assigns.access) enforce_access!(:scale_queues, socket.assigns.access)
@ -112,18 +201,6 @@ defmodule Oban.Web.Queues.DetailInsanceComponent do
send(self(), {:scale_queue, checks["queue"], checks["name"], checks["node"], limit}) send(self(), {:scale_queue, checks["queue"], checks["name"], checks["node"], limit})
{:noreply, assign(socket, local_limit: limit)} {:noreply, assign(socket, local_limit: limit, editing?: false)}
end
def handle_event("increment", _params, socket) do
{:noreply, assign(socket, local_limit: socket.assigns.local_limit + 1)}
end
def handle_event("decrement", _params, socket) do
if socket.assigns.local_limit > 1 do
{:noreply, assign(socket, local_limit: socket.assigns.local_limit - 1)}
else
{:noreply, socket}
end
end end
end end

View file

@ -1,63 +0,0 @@
defmodule Oban.Web.Queues.SidebarComponent do
use Oban.Web, :html
alias Oban.Web.Queue
alias Oban.Web.SidebarComponents
attr :queues, :list
attr :params, :map
attr :csp_nonces, :map
attr :width, :integer, default: 320
def sidebar(assigns) do
~H"""
<SidebarComponents.sidebar width={@width} csp_nonces={@csp_nonces}>
<SidebarComponents.section name="stats" headers={~w(count)}>
<SidebarComponents.filter_row
name="paused"
active={active_filter?(@params, :stats, :paused)}
patch={patch_params(@params, :queues, :stats, :paused)}
values={[Enum.count(@queues, &Queue.any_paused?/1)]}
/>
<SidebarComponents.filter_row
name="terminating"
active={active_filter?(@params, :stats, :terminating)}
patch={patch_params(@params, :queues, :stats, :terminating)}
values={[Enum.count(@queues, &Queue.terminating?/1)]}
/>
</SidebarComponents.section>
<SidebarComponents.section name="modes" headers={~w(count)}>
<SidebarComponents.filter_row
name="global-limit"
active={active_filter?(@params, :modes, :global_limit)}
patch={patch_params(@params, :queues, :modes, :global_limit)}
values={[Enum.count(@queues, &Queue.global_limit?/1)]}
/>
<SidebarComponents.filter_row
name="rate-limit"
active={active_filter?(@params, :modes, :rate_limit)}
patch={patch_params(@params, :queues, :modes, :rate_limit)}
values={[Enum.count(@queues, &Queue.rate_limit?/1)]}
/>
</SidebarComponents.section>
<SidebarComponents.section name="nodes" headers={~w(count)}>
<SidebarComponents.filter_row
:for={{node, count} <- nodes(@queues)}
name={node}
active={active_filter?(@params, :nodes, node)}
patch={patch_params(@params, :queues, :nodes, node)}
values={[count]}
/>
</SidebarComponents.section>
</SidebarComponents.sidebar>
"""
end
defp nodes(queues) do
queues
|> Enum.flat_map(& &1.checks)
|> Enum.reduce(%{}, fn %{"node" => node}, acc -> Map.update(acc, node, 1, &(&1 + 1)) end)
end
end

View file

@ -1,42 +1,61 @@
defmodule Oban.Web.Queues.TableComponent do defmodule Oban.Web.Queues.TableComponent do
use Oban.Web, :live_component use Oban.Web, :live_component
import Oban.Web.Helpers, only: [integer_to_estimate: 1, oban_path: 1]
import Oban.Web.Helpers.QueueHelper import Oban.Web.Helpers.QueueHelper
alias Oban.Web.{Queue, Timing} alias Oban.Web.Components.Core
alias Oban.Web.Queue
@impl Phoenix.LiveComponent @impl Phoenix.LiveComponent
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div id="queues-table" class="min-w-full"> <div id="queues-table" class="min-w-full">
<ul class="flex items-center border-b border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-600"> <ul class="flex items-center border-b border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500">
<.queue_header label="name" class="ml-12 w-1/3 text-left" /> <.queue_header label="name" class="ml-12 w-1/4 text-left" />
<div class="ml-auto flex items-center space-x-6"> <div class="ml-auto flex items-center space-x-6">
<.queue_header label="nodes" class="w-16 text-right" /> <.queue_header label="utilization" class="w-56 text-center" />
<.queue_header label="exec" class="w-16 text-right" /> <.queue_header label="history" class="w-80 text-center" />
<.queue_header label="avail" class="w-16 text-right" /> <.queue_header label="pending" class="w-42 text-center" />
<.queue_header label="local" class="w-16 text-right" /> <.queue_header label="nodes" class="w-20 text-center" />
<.queue_header label="global" class="w-16 text-right" />
<.queue_header label="rate limit" class="w-32 text-right" />
<.queue_header label="started" class="w-28 text-right" />
<.queue_header label="status" class="w-20 pr-3 text-right" /> <.queue_header label="status" class="w-20 pr-3 text-right" />
</div> </div>
</ul> </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" />
<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.
</p>
<div class="mt-4">
<a
href="https://hexdocs.pm/oban/defining_queues.html"
target="_blank"
rel="noopener"
class="text-base font-medium text-violet-600 hover:text-violet-500 dark:text-violet-400 dark:hover:text-violet-300"
>
Learn about queues <span aria-hidden="true">&rarr;</span>
</a>
</div>
</div>
<div <div
:if={Enum.empty?(@queues)} :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" 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 running match the current set of filters.</span> <Icons.no_symbol /> <span>No queues match the current filters.</span>
</div> </div>
<ul class="divide-y divide-gray-100 dark:divide-gray-800"> <ul class="divide-y divide-gray-100 dark:divide-gray-800">
<.queue_row <.queue_row
:for={queue <- @queues} :for={queue <- @queues}
access={@access} access={@access}
history={Map.get(@history, queue.name, %{})}
myself={@myself} myself={@myself}
queue={queue} queue={queue}
selected={MapSet.member?(@selected, queue.name)} selected={MapSet.member?(@selected, queue.name)}
total_limit={Queue.local_limit(queue)}
/> />
</ul> </ul>
</div> </div>
@ -57,9 +76,11 @@ defmodule Oban.Web.Queues.TableComponent do
end end
attr :access, :map, required: true attr :access, :map, required: true
attr :history, :map, required: true
attr :myself, :any, required: true attr :myself, :any, required: true
attr :queue, :string, required: true attr :queue, :string, required: true
attr :selected, :boolean, default: false attr :selected, :boolean, default: false
attr :total_limit, :integer, required: true
defp queue_row(assigns) do defp queue_row(assigns) do
~H""" ~H"""
@ -75,43 +96,116 @@ defmodule Oban.Web.Queues.TableComponent do
/> />
<.link patch={oban_path([:queues, @queue.name])} class="py-5 flex flex-grow items-center"> <.link patch={oban_path([:queues, @queue.name])} class="py-5 flex flex-grow items-center">
<div rel="name" class="w-1/3 font-semibold text-gray-700 dark:text-gray-300"> <div rel="name" class="w-1/4 font-semibold text-gray-700 dark:text-gray-300">
{@queue.name} {@queue.name}
</div> </div>
<div class="ml-auto flex items-center space-x-6 tabular text-gray-500 dark:text-gray-300"> <div class="ml-auto flex items-center space-x-6">
<span rel="nodes" class="w-16 text-right"> <% {exec, limit, percent} = utilization(@queue) %>
<div rel="utilization" class="w-56 flex items-center text-gray-500 dark:text-gray-300">
<span
class="flex items-center"
data-title="Executing / Limit"
id={"#{@queue.name}-util"}
phx-hook="Tippy"
>
<div class="w-28 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div class="h-full rounded-full bg-emerald-400" style={"width: #{percent}%"} />
</div>
<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
:if={Queue.global_limit?(@queue)}
class="w-4 h-4"
data-title="Global limit"
id={"#{@queue.name}-has-global"}
phx-hook="Tippy"
/>
<Icons.arrow_trending_down
:if={Queue.rate_limit?(@queue)}
class="w-4 h-4"
data-title="Rate limit"
id={"#{@queue.name}-has-rate"}
phx-hook="Tippy"
/>
<Icons.view_columns
:if={Queue.partitioned?(@queue)}
class="w-4 h-4"
data-title="Partitioned"
id={"#{@queue.name}-has-partition"}
phx-hook="Tippy"
/>
</div>
</div>
<div class="w-80 flex justify-center">
<Core.sparkline
id={"sparkline-#{@queue.name}"}
history={@history}
max_value={@total_limit}
/>
</div>
<div
rel="pending"
class="w-42 flex items-center justify-end tabular text-gray-500 dark:text-gray-300"
>
<span
class="w-14 flex items-center space-x-1.5"
data-title="Available"
id={"#{@queue.name}-avail"}
phx-hook="Tippy"
>
<span class="flex-1 text-right">{integer_to_estimate(@queue.counts.available)}</span>
<span class={[
"w-2 h-2 rounded-full",
if(@queue.counts.available > 0,
do: "bg-cyan-400",
else: "bg-gray-300 dark:bg-gray-600"
)
]} />
</span>
<span
class="w-14 flex items-center space-x-1.5"
data-title="Scheduled"
id={"#{@queue.name}-sched"}
phx-hook="Tippy"
>
<span class="flex-1 text-right">{integer_to_estimate(@queue.counts.scheduled)}</span>
<span class={[
"w-2 h-2 rounded-full",
if(@queue.counts.scheduled > 0,
do: "bg-emerald-400",
else: "bg-gray-300 dark:bg-gray-600"
)
]} />
</span>
<span
class="w-14 flex items-center space-x-1.5"
data-title="Retryable"
id={"#{@queue.name}-retry"}
phx-hook="Tippy"
>
<span class="flex-1 text-right">{integer_to_estimate(@queue.counts.retryable)}</span>
<span class={[
"w-2 h-2 rounded-full",
if(@queue.counts.retryable > 0,
do: "bg-yellow-400",
else: "bg-gray-300 dark:bg-gray-600"
)
]} />
</span>
</div>
<span rel="nodes" class="w-14 text-center text-gray-500 dark:text-gray-300">
{length(@queue.checks)} {length(@queue.checks)}
</span> </span>
<span rel="executing" class="w-16 text-right"> <div class="w-20 pr-3 flex justify-center items-center space-x-1">
{@queue.counts.executing}
</span>
<span rel="available" class="w-16 text-right">
{integer_to_estimate(@queue.counts.available)}
</span>
<span rel="local" class="w-16 text-right">
{local_limit(@queue)}
</span>
<span rel="global" class="w-16 text-right">
{global_limit(@queue)}
</span>
<span rel="rate" class="w-32 text-right">
{rate_limit(@queue)}
</span>
<span rel="started" class="w-28 text-right">
{started_at(@queue)}
</span>
<div class="w-20 pr-3 flex justify-end items-center space-x-1">
<Icons.pause_circle <Icons.pause_circle
:if={Queue.all_paused?(@queue)} :if={Queue.all_paused?(@queue)}
class="w-4 h-4" class="w-5 h-5"
data-title="All paused" data-title="All paused"
id={"#{@queue.name}-is-paused"} id={"#{@queue.name}-is-paused"}
phx-hook="Tippy" phx-hook="Tippy"
@ -119,7 +213,7 @@ defmodule Oban.Web.Queues.TableComponent do
/> />
<Icons.play_pause_circle <Icons.play_pause_circle
:if={Queue.any_paused?(@queue) and not Queue.all_paused?(@queue)} :if={Queue.any_paused?(@queue) and not Queue.all_paused?(@queue)}
class="w-4 h-4" class="w-5 h-5"
data-title="Some paused" data-title="Some paused"
id={"#{@queue.name}-is-some-paused"} id={"#{@queue.name}-is-some-paused"}
phx-hook="Tippy" phx-hook="Tippy"
@ -127,7 +221,7 @@ defmodule Oban.Web.Queues.TableComponent do
/> />
<Icons.power <Icons.power
:if={Queue.terminating?(@queue)} :if={Queue.terminating?(@queue)}
class="w-4 h-4" class="w-5 h-5"
data-title="Terminating" data-title="Terminating"
id={"#{@queue.name}-is-terminating"} id={"#{@queue.name}-is-terminating"}
phx-hook="Tippy" phx-hook="Tippy"
@ -151,54 +245,10 @@ defmodule Oban.Web.Queues.TableComponent do
# Helpers # Helpers
defp local_limit(queue) do defp utilization(queue) do
queue.checks exec = executing_count(queue.checks)
|> Enum.map(& &1["local_limit"]) limit = Queue.local_limit(queue)
|> Enum.min_max() percent = if limit > 0, do: min(round(exec / limit * 100), 100), else: 0
|> case do {exec, limit, percent}
{min, min} -> min
{min, max} -> "#{min}..#{max}"
end
end
defp global_limit(queue) do
Queue.global_limit(queue) || "-"
end
defp rate_limit(queue) do
queue.checks
|> Enum.map(& &1["rate_limit"])
|> Enum.reject(&is_nil/1)
|> case do
[head_limit | _rest] = rate_limits ->
%{"allowed" => allowed, "period" => period, "window_time" => time} = head_limit
{prev_total, curr_total} =
rate_limits
|> Enum.flat_map(& &1["windows"])
|> Enum.reduce({0, 0}, fn %{"prev_count" => pcnt, "curr_count" => ccnt}, {pacc, cacc} ->
{pacc + pcnt, cacc + ccnt}
end)
unix_now = DateTime.to_unix(DateTime.utc_now(), :second)
ellapsed = unix_now - time_to_unix(time)
weight = div(max(period - ellapsed, 0), period)
remaining = prev_total * weight + curr_total
period_in_words = Timing.to_words(period, relative: false)
"#{remaining}/#{allowed} per #{period_in_words}"
[] ->
"-"
end
end
defp time_to_unix(unix) when is_integer(unix), do: unix
defp time_to_unix(time) do
Date.utc_today()
|> DateTime.new!(Time.from_iso8601!(time))
|> DateTime.to_unix(:second)
end end
end end

View file

@ -6,6 +6,7 @@ defmodule Oban.Web.RefreshComponent do
{02, "2s"}, {02, "2s"},
{05, "5s"}, {05, "5s"},
{15, "15s"}, {15, "15s"},
{30, "30s"},
{60, "1m"}, {60, "1m"},
{-1, "Off"} {-1, "Off"}
] ]
@ -28,7 +29,7 @@ defmodule Oban.Web.RefreshComponent do
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded="true" aria-expanded="true"
aria-labelledby="listbox-label" aria-labelledby="listbox-label"
class="text-gray-500 dark:text-gray-400 focus:outline-none hover:text-gray-600 dark:hover:text-gray-200 hidden md:flex" class="cursor-pointer text-gray-500 dark:text-gray-400 focus:outline-none hover:text-gray-700 dark:hover:text-gray-200 hidden md:flex"
data-title="Change refresh rate" data-title="Change refresh rate"
id="refresh-menu-toggle" id="refresh-menu-toggle"
phx-hook="Tippy" phx-hook="Tippy"
@ -40,7 +41,7 @@ defmodule Oban.Web.RefreshComponent do
</button> </button>
<ul <ul
class="hidden absolute z-50 top-full right-0 mt-2 w-18 overflow-hidden rounded-md shadow-lg text-sm font-semibold bg-white dark:bg-gray-800 focus:outline-none" class="hidden absolute z-50 top-full right-0 mt-2 py-2 w-18 overflow-hidden rounded-md shadow-lg text-sm font-semibold bg-white dark:bg-gray-800 focus:outline-none"
id="refresh-menu" id="refresh-menu"
role="listbox" role="listbox"
tabindex="-1" tabindex="-1"

View file

@ -58,7 +58,7 @@ defmodule Oban.Web.SearchComponent do
aria-label="Add filters" aria-label="Add filters"
aria-placeholder="Add filters" aria-placeholder="Add filters"
autocorrect="false" autocorrect="false"
class="min-w-[10rem] flex-grow my-2 px-0 py-0.5 text-sm appearance-none border-none bg-transparent placeholder-gray-400 dark:placeholder-gray-600 focus:ring-0" class="min-w-[10rem] flex-grow my-2 px-0 py-0.5 text-sm appearance-none border-none bg-transparent placeholder-gray-400 dark:placeholder-gray-500 focus:ring-0"
id="search-input" id="search-input"
name="terms" name="terms"
phx-blur={hide_focus()} phx-blur={hide_focus()}
@ -132,7 +132,7 @@ defmodule Oban.Web.SearchComponent do
</span> </span>
<button <button
class="pl-0.5 pr-1 py-1 rounded-e-md text-gray-800/70 bg-violet-100 dark:bg-violet-300 hover:bg-violet-500 dark:hover:bg-violet-500 hover:text-gray-100" class="flex items-center pl-0.5 pr-1 py-1 rounded-e-md text-gray-800/70 bg-violet-100 dark:bg-violet-300 hover:bg-violet-500 dark:hover:bg-violet-500 hover:text-gray-100"
type="button" type="button"
phx-click="remove-filter" phx-click="remove-filter"
phx-value-param={@param} phx-value-param={@param}

View file

@ -42,6 +42,8 @@ defmodule Oban.Web.ShortcutsComponent do
<dl class="divide-y divide-gray-100 dark:divide-gray-900 text-sm"> <dl class="divide-y divide-gray-100 dark:divide-gray-900 text-sm">
<.list_item description="Go to jobs" shortcut="J" /> <.list_item description="Go to jobs" shortcut="J" />
<.list_item description="Go to queues" shortcut="Q" /> <.list_item description="Go to queues" shortcut="Q" />
<.list_item description="Go to crons" shortcut="C" />
<.list_item description="Go to workflows" shortcut="W" />
<.list_item description="Focus search" shortcut="/" /> <.list_item description="Focus search" shortcut="/" />
<.list_item description="Toggle refresh" shortcut="r" /> <.list_item description="Toggle refresh" shortcut="r" />

View file

@ -13,7 +13,7 @@ defmodule Oban.Web.ThemeComponent do
<button <button
aria-expanded="true" aria-expanded="true"
aria-haspopup="listbox" aria-haspopup="listbox"
class="text-gray-500 dark:text-gray-400 focus:outline-none hover:text-gray-600 dark:hover:text-gray-200 hidden md:block" class="cursor-pointer text-gray-500 dark:text-gray-400 focus:outline-none hover:text-gray-700 dark:hover:text-gray-200 hidden md:block"
data-title="Change theme" data-title="Change theme"
id="theme-menu-toggle" id="theme-menu-toggle"
phx-hook="Tippy" phx-hook="Tippy"
@ -24,7 +24,7 @@ defmodule Oban.Web.ThemeComponent do
</button> </button>
<ul <ul
class="hidden absolute z-50 top-full right-0 mt-2 w-32 overflow-hidden rounded-md shadow-lg text-sm font-semibold bg-white dark:bg-gray-800 focus:outline-none" class="hidden absolute z-50 top-full right-0 mt-2 py-2 w-32 overflow-hidden rounded-md shadow-lg text-sm font-semibold bg-white dark:bg-gray-800 focus:outline-none"
id="theme-menu" id="theme-menu"
role="listbox" role="listbox"
tabindex="-1" tabindex="-1"

View file

@ -0,0 +1,606 @@
defmodule Oban.Web.Workflows.DetailComponent do
use Oban.Web, :live_component
alias Oban.Web.Components.Core
alias Oban.Web.Timing
alias Oban.Web.WorkflowQuery
@states ~w(suspended available scheduled executing retryable completed cancelled discarded)a
@impl Phoenix.LiveComponent
def update(assigns, socket) do
sub_workflows = assigns[:sub_workflows] || []
socket =
socket
|> assign(assigns)
|> assign(:sub_workflows, sub_workflows)
|> assign_new(:graph_open?, fn -> true end)
|> assign_new(:subs_open?, fn -> match?([_ | _], sub_workflows) end)
|> assign_new(:graph_data, fn -> %{jobs: [], sub_workflows: []} end)
|> push_graph_data()
{:ok, socket}
end
defp push_graph_data(socket) do
if socket.assigns[:graph_open?] do
push_event(socket, "graph-data", socket.assigns.graph_data)
else
socket
end
end
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div id="workflow-details">
<%= if @workflow do %>
<.header
access={@access}
myself={@myself}
pro_available?={@pro_available?}
workflow={@workflow}
parent_workflow={@parent_workflow}
/>
<div class="grid grid-cols-6 gap-6 px-3 py-6">
<div class="col-span-4">
<.progress_bar workflow={@workflow} subs={@sub_workflows} />
</div>
<div class="col-span-2">
<.stats_grid workflow={@workflow} sub_workflows={@sub_workflows} />
</div>
</div>
<.graph_section myself={@myself} graph_open?={@graph_open?} graph_data={@graph_data} />
<.sub_workflows_section
myself={@myself}
subs_open?={@subs_open?}
sub_workflows={@sub_workflows}
/>
<% 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" />
<h3 class="mt-4 text-xl font-semibold text-gray-900 dark:text-gray-100">
Workflow not found
</h3>
<p class="mt-2 text-base text-gray-500 dark:text-gray-400">
This workflow may have been deleted or doesn't exist.
</p>
</div>
</div>
<% end %>
</div>
"""
end
# Header
attr :access, :any, required: true
attr :myself, :any, required: true
attr :pro_available?, :boolean, required: true
attr :workflow, :map, required: true
attr :parent_workflow, :map, default: nil
defp header(assigns) do
~H"""
<div class="flex justify-between items-center px-3 py-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center">
<button
id="back-link"
class="flex items-center hover:text-blue-500 cursor-pointer bg-transparent border-0 p-0"
data-escape-back={true}
phx-hook="HistoryBack"
type="button"
>
<Icons.arrow_left class="w-5 h-5" />
<span class="text-lg font-bold ml-2">{@workflow.name || @workflow.id}</span>
</button>
<.parent_breadcrumb :if={@parent_workflow} parent={@parent_workflow} />
</div>
<div class="flex space-x-3">
<Core.icon_button
id="detail-cancel"
icon="x_circle"
label="Cancel"
color="yellow"
tooltip={cancel_tooltip(@pro_available?)}
disabled={
not @pro_available? or not can?(:cancel_workflows, @access) or
@workflow.state in ~w(completed cancelled)
}
phx-target={@myself}
phx-click="cancel-workflow"
/>
<Core.icon_button
id="detail-retry"
icon="arrow_path"
label="Retry"
color="blue"
tooltip={retry_tooltip(@pro_available?)}
disabled={
not @pro_available? or not can?(:retry_workflows, @access) or
not has_retryable?(@workflow)
}
phx-target={@myself}
phx-click="retry-workflow"
/>
</div>
</div>
"""
end
attr :parent, :map, required: true
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" />
<span>sub-workflow of</span>
<.link
navigate={oban_path([:workflows, @parent.id])}
class="ml-1 font-medium text-violet-600 hover:text-violet-500 dark:text-violet-400"
>
{@parent.name || @parent.id}
</.link>
</div>
"""
end
# Progress Bar
attr :workflow, :map, required: true
attr :subs, :list, default: []
defp progress_bar(assigns) do
wf = assigns.workflow
sub = count_sub_states(assigns.subs)
total = Enum.reduce(@states, 0, &(Map.fetch!(wf, &1) + &2)) + length(assigns.subs)
completed = wf.completed + sub.completed
states = [
{:suspended, wf.suspended + sub.suspended, "bg-gray-400", "Suspended"},
{:scheduled, wf.scheduled + sub.scheduled, "bg-indigo-400", "Scheduled"},
{:available, wf.available + sub.available, "bg-blue-400", "Available"},
{:retryable, wf.retryable + sub.retryable, "bg-yellow-400", "Retryable"},
{:executing, wf.executing + sub.executing, "bg-emerald-400", "Executing"},
{:completed, completed, "bg-cyan-400", "Completed"},
{:cancelled, wf.cancelled + sub.cancelled, "bg-violet-400", "Cancelled"},
{:discarded, wf.discarded + sub.discarded, "bg-rose-400", "Discarded"}
]
percent = if total > 0, do: round(completed / total * 100), else: 0
assigns =
assign(assigns, states: states, total: total, completed: completed, percent: percent)
~H"""
<div class="bg-gray-50 dark:bg-gray-800 rounded-md p-4" id="workflow-progress">
<div class="flex items-center justify-between mb-3">
<span class="text-base font-semibold text-gray-700 dark:text-gray-200">
{@percent}% Complete
</span>
<span class="text-base text-gray-500 dark:text-gray-400 tabular">
{@completed}/{@total} jobs
</span>
</div>
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
<%= for {_state, count, color, _label} <- @states, count > 0 do %>
<div class={["h-full", color]} style={"width: #{count / max(@total, 1) * 100}%"} />
<% end %>
</div>
<div class="flex justify-between mt-3">
<%= for {_state, count, color, label} <- @states do %>
<div class="group flex items-center text-sm px-2 py-1 -mx-2 -my-1 rounded-full hover:bg-white dark:hover:bg-white/20 transition-all duration-200 cursor-default">
<span class={["w-3 h-3 rounded-full shrink-0 mr-1.5", color]} />
<span class="text-gray-600 dark:text-gray-400 whitespace-nowrap overflow-hidden transition-all duration-200 w-10 group-hover:w-20 [mask-image:linear-gradient(to_right,black_60%,transparent_100%)] group-hover:[mask-image:none]">
{label}
</span>
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300 tabular">
{count}
</span>
</div>
<% end %>
</div>
</div>
"""
end
# Graph Section
attr :myself, :any, required: true
attr :graph_open?, :boolean, required: true
attr :graph_data, :map, required: true
defp graph_section(assigns) do
~H"""
<div class="border-t border-gray-200 dark:border-gray-700">
<div class="px-3 py-6">
<button
id="graph-toggle"
type="button"
class="flex items-center w-full space-x-2 px-2 py-1.5 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
phx-click="toggle-graph"
phx-target={@myself}
>
<Icons.chevron_right class={[
"w-5 h-5 transition-transform",
@graph_open? && "rotate-90"
]} />
<span class="font-semibold">Workflow Graph</span>
</button>
<div :if={@graph_open?} class="mt-3">
<div
id="workflow-graph"
class="h-96 rounded-md overflow-hidden shadow-inner bg-gray-50 dark:bg-gray-900 workflow-graph-canvas"
phx-hook="WorkflowGraph"
phx-target={@myself}
phx-update="ignore"
>
<svg class="w-full h-full"></svg>
</div>
</div>
</div>
</div>
"""
end
# Stats Grid
attr :workflow, :any, required: true
attr :sub_workflows, :list, required: true
defp stats_grid(assigns) do
queues = Map.get(assigns.workflow.meta || %{}, "queues", [])
assigns = assign(assigns, queues: queues)
~H"""
<div class="flex flex-col gap-4 h-full" id="workflow-stats">
<div class="grid grid-cols-4 gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
<div class="flex flex-col col-span-4">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Workflow ID
</span>
<span class="text-base text-gray-800 dark:text-gray-200 font-mono">
{@workflow.id}
</span>
</div>
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Started
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
<.format_started_at workflow={@workflow} />
</span>
</div>
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Duration
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
<.format_duration workflow={@workflow} />
</span>
</div>
<div class="flex flex-col col-span-2">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Status
</span>
<span class="text-base text-gray-800 dark:text-gray-200 capitalize">
{@workflow.state}
</span>
</div>
</div>
<div class="grid grid-cols-4 gap-4 px-3">
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Subs
</span>
<span class="text-base text-gray-800 dark:text-gray-200 tabular">
{length(@sub_workflows)}
</span>
</div>
<div class="flex flex-col col-span-3">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Queues
</span>
<div class="flex flex-wrap items-center gap-1.5">
<span
:for={queue <- @queues}
class="inline-flex items-center px-1.5 py-0.5 rounded text-sm bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
>
{queue}
</span>
</div>
</div>
</div>
</div>
"""
end
attr :workflow, :any, required: true
defp format_started_at(assigns) do
wf = assigns.workflow
executed? = wf.executing + wf.completed > 0
started = if executed?, do: wf.started_at
assigns = assign(assigns, started: started)
~H"""
<span
:if={@started}
id={"wf-detail-started-#{@workflow.id}"}
data-timestamp={DateTime.to_unix(@started, :millisecond)}
phx-hook="Relativize"
phx-update="ignore"
>
-
</span>
<span :if={is_nil(@started)}></span>
"""
end
attr :workflow, :any, required: true
defp format_duration(assigns) do
wf = assigns.workflow
executing? = wf.state == "executing"
started? = not is_nil(wf.started_at)
duration =
if wf.started_at && wf.completed_at do
DateTime.diff(wf.completed_at, wf.started_at, :millisecond)
end
formatted =
if is_nil(duration) or duration <= 0 do
""
else
duration |> div(1000) |> Timing.to_duration()
end
assigns =
assign(assigns,
executing?: executing?,
started?: started?,
formatted: formatted,
started_at: wf.started_at
)
~H"""
<span
:if={@executing? and @started?}
id={"wf-detail-duration-#{@workflow.id}"}
data-timestamp={DateTime.to_unix(@started_at, :millisecond)}
data-relative-mode="duration"
phx-hook="Relativize"
phx-update="ignore"
>
-
</span>
<span :if={not (@executing? and @started?)}>
{@formatted}
</span>
"""
end
# Sub-workflows Section
attr :myself, :any, required: true
attr :subs_open?, :boolean, required: true
attr :sub_workflows, :list, required: true
defp sub_workflows_section(assigns) do
subs_count = length(assigns.sub_workflows)
assigns = assign(assigns, subs_count: subs_count)
~H"""
<div class="border-t border-gray-200 dark:border-gray-700">
<div class="px-3 py-6">
<button
id="subs-toggle"
type="button"
class="flex items-center w-full space-x-2 px-2 py-1.5 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
phx-click="toggle-subs"
phx-target={@myself}
>
<Icons.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">
({@subs_count})
</span>
</span>
</button>
<div :if={@subs_open? and @subs_count > 0} class="mt-3">
<div class="bg-gray-50 dark:bg-gray-800 rounded-md overflow-hidden border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead>
<tr class="bg-gray-50 dark:bg-gray-950 text-gray-500 dark:text-gray-500">
<th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider">
Name
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider">
ID
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider">
Progress
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider">
Started
</th>
<th class="px-3 py-2 text-right text-xs font-medium uppercase tracking-wider">
Duration
</th>
<th class="px-3 py-2 text-center text-xs font-medium uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800 bg-white dark:bg-gray-900">
<.sub_workflow_row :for={sub <- @sub_workflows} workflow={sub} />
</tbody>
</table>
</div>
</div>
</div>
</div>
"""
end
attr :workflow, :any, required: true
defp sub_workflow_row(assigns) do
wf = assigns.workflow
total = Enum.reduce(@states, 0, &(Map.fetch!(wf, &1) + &2))
percent = if total > 0, do: round(wf.completed / total * 100), else: 0
assigns =
assign(assigns,
completed: wf.completed,
total: total,
percent: percent,
state: wf.state
)
~H"""
<.link navigate={oban_path([:workflows, @workflow.id])} class="contents">
<tr class="hover:bg-gray-100 dark:hover:bg-gray-700/50 cursor-pointer">
<td class="px-3 py-3 font-medium text-sm text-gray-700 dark:text-gray-300">
{@workflow.name || @workflow.id}
</td>
<td class="px-3 py-3 text-sm text-gray-500 dark:text-gray-400 font-mono">
{@workflow.id}
</td>
<td class="px-3 py-3">
<div class="flex items-center">
<div class="w-24 h-1.5 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden mr-2">
<div class="h-full rounded-full bg-cyan-400" style={"width: #{@percent}%"} />
</div>
<span class="text-sm text-gray-500 dark:text-gray-400 tabular">
{@completed}/{@total}
</span>
</div>
</td>
<td class="px-3 py-3 text-sm text-gray-500 dark:text-gray-400">
<.format_started_at workflow={@workflow} />
</td>
<td class="px-3 py-3 text-right text-sm text-gray-500 dark:text-gray-400">
<.format_duration workflow={@workflow} />
</td>
<td class="px-3 py-3 text-center">
<.status_icon state={@state} />
</td>
</tr>
</.link>
"""
end
attr :state, :string, default: nil
defp status_icon(assigns) do
~H"""
<span class="inline-flex">
<%= case @state do %>
<% "executing" -> %>
<Icons.play_circle class="w-5 h-5 text-emerald-400" />
<% "completed" -> %>
<Icons.check_circle class="w-5 h-5 text-cyan-400" />
<% "retryable" -> %>
<Icons.arrow_path class="w-5 h-5 text-yellow-400" />
<% "cancelled" -> %>
<Icons.x_circle class="w-5 h-5 text-violet-400" />
<% "discarded" -> %>
<Icons.exclamation_circle class="w-5 h-5 text-rose-400" />
<% _ -> %>
<Icons.minus_circle class="w-5 h-5 text-gray-400" />
<% end %>
</span>
"""
end
# Event Handlers
@impl Phoenix.LiveComponent
def handle_event("cancel-workflow", _params, socket) do
send(self(), {:cancel_workflow, socket.assigns.workflow.id})
{:noreply, socket}
end
def handle_event("retry-workflow", _params, socket) do
send(self(), {:retry_workflow, socket.assigns.workflow.id})
{:noreply, socket}
end
def handle_event("toggle-graph", _params, socket) do
graph_open? = not socket.assigns[:graph_open?]
socket = assign(socket, :graph_open?, graph_open?)
socket = if graph_open?, do: push_graph_data(socket), else: socket
{:noreply, socket}
end
def handle_event("toggle-subs", _params, socket) do
{:noreply, assign(socket, :subs_open?, not socket.assigns[:subs_open?])}
end
def handle_event("navigate-to-job", %{"job_id" => job_id}, socket) do
{:noreply, push_navigate(socket, to: oban_path([:jobs, job_id]))}
end
def handle_event("navigate-to-workflow", %{"workflow_id" => workflow_id}, socket) do
{:noreply, push_navigate(socket, to: oban_path([:workflows, workflow_id]))}
end
def handle_event("expand-sub-workflow", %{"workflow_id" => sub_workflow_id}, socket) do
%{jobs: jobs, truncated: truncated} =
WorkflowQuery.get_sub_workflow_jobs(socket.assigns.conf, sub_workflow_id)
payload = %{workflow_id: sub_workflow_id, jobs: jobs, truncated: truncated}
socket = push_event(socket, "sub-workflow-jobs", payload)
{:noreply, socket}
end
# Helpers
defp cancel_tooltip(true), do: "Cancel all jobs in this workflow"
defp cancel_tooltip(false), do: "Cancel requires Oban Pro"
defp retry_tooltip(true), do: "Retry failed jobs in this workflow"
defp retry_tooltip(false), do: "Retry requires Oban Pro"
defp has_retryable?(workflow) do
workflow.retryable + workflow.discarded + workflow.cancelled > 0
end
defp count_sub_states(subs) do
init = Map.new(@states, &{&1, 0})
Enum.reduce(subs, init, fn sub, acc ->
Map.update!(acc, String.to_existing_atom(sub.state), &(&1 + 1))
end)
end
end

View file

@ -0,0 +1,271 @@
defmodule Oban.Web.Workflows.TableComponent do
use Oban.Web, :live_component
import Oban.Web.Helpers, only: [integer_to_estimate: 1, oban_path: 1]
alias Oban.Web.Timing
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div id="workflows-table" class="min-w-full">
<ul class="flex items-center border-b border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500">
<.header label="name" class="pl-3 w-1/3 text-left" />
<div class="ml-auto flex items-center space-x-6">
<.header label="progress" class="w-64 text-center" />
<.header label="activity" class="w-44 text-center" />
<.header label="duration" class="w-24 text-right" />
<.header label="started" class="w-24 text-right" />
<.header label="status" class="w-16 pr-4 text-right" />
</div>
</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" />
<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.
</p>
<div class="mt-4">
<a
href="https://oban.pro/docs/pro/Oban.Pro.Workflow.html"
target="_blank"
rel="noopener"
class="text-base font-medium text-violet-600 hover:text-violet-500 dark:text-violet-400 dark:hover:text-violet-300"
>
Learn about workflows <span aria-hidden="true">&rarr;</span>
</a>
</div>
</div>
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
<.workflow_row :for={workflow <- @workflows} workflow={workflow} />
</ul>
</div>
"""
end
attr :label, :string, required: true
attr :class, :string, default: ""
defp header(assigns) do
~H"""
<span class={[@class, "text-xs font-medium uppercase tracking-wider py-1.5"]}>
{@label}
</span>
"""
end
attr :workflow, :map, required: true
defp workflow_row(assigns) do
~H"""
<li id={"workflow-#{@workflow.id}"}>
<.link
patch={oban_path([:workflows, @workflow.id])}
class="flex items-center hover:bg-gray-50 dark:hover:bg-gray-950/30"
>
<div class="pl-3 py-3.5 flex flex-grow items-center">
<div class="w-1/3">
<span class="font-semibold text-sm text-gray-700 dark:text-gray-300">
{@workflow.display_name}
</span>
<div :if={Enum.any?(@workflow.queues)} class="flex flex-wrap items-center gap-1.5 mt-1">
<span
:for={queue <- @workflow.queues}
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
>
{queue}
</span>
</div>
</div>
<div class="ml-auto flex items-center space-x-6 tabular text-gray-500 dark:text-gray-300">
<.progress_bar workflow={@workflow} />
<.activity_counts workflow={@workflow} />
<.format_duration workflow={@workflow} />
<.started_at workflow={@workflow} />
<div class="w-16 pr-4 flex justify-end">
<.status_indicator id={@workflow.id} state={@workflow.state} />
</div>
</div>
</div>
</.link>
</li>
"""
end
attr :workflow, :map, required: true
defp progress_bar(assigns) do
activity = assigns.workflow.activity
total = assigns.workflow.total
finished = activity.finished
percent = if total > 0, do: min(round(finished / total * 100), 100), else: 0
assigns = assign(assigns, finished: finished, total: total, percent: percent)
~H"""
<div class="w-64 flex items-center">
<div class="w-48 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div class="h-full rounded-full bg-cyan-400" style={"width: #{@percent}%"} />
</div>
<span class="w-14 text-left tabular pl-2 text-sm">{@finished}/{@total}</span>
</div>
"""
end
attr :workflow, :map, required: true
defp activity_counts(assigns) do
activity = assigns.workflow.activity
id = assigns.workflow.id
assigns =
assign(assigns,
id: id,
suspended: activity.suspended,
pending: activity.pending,
executing: activity.executing,
finished: activity.finished
)
~H"""
<div class="w-44 flex items-center justify-end text-sm">
<.state_count id={"#{@id}-susp"} count={@suspended} color="gray" title="Suspended" />
<.state_count id={"#{@id}-pend"} count={@pending} color="blue" title="Pending" />
<.state_count id={"#{@id}-exec"} count={@executing} color="emerald" title="Executing" />
<.state_count id={"#{@id}-fin"} count={@finished} color="cyan" title="Finished" />
</div>
"""
end
attr :id, :string, required: true
attr :count, :integer, required: true
attr :color, :string, required: true
attr :title, :string, required: true
defp state_count(assigns) do
bg_class =
case {assigns.count, assigns.color} do
{0, _} -> "bg-gray-300 dark:bg-gray-600"
{_, "gray"} -> "bg-gray-400"
{_, "blue"} -> "bg-blue-400"
{_, "emerald"} -> "bg-emerald-400"
{_, "cyan"} -> "bg-cyan-400"
end
assigns = assign(assigns, bg_class: bg_class)
~H"""
<span class="w-11 flex items-center space-x-1.5" data-title={@title} id={@id} phx-hook="Tippy">
<span class="flex-1 text-right">{integer_to_estimate(@count)}</span>
<span class={["w-2 h-2 rounded-full", @bg_class]} />
</span>
"""
end
attr :workflow, :map, required: true
defp started_at(assigns) do
activity = assigns.workflow.activity
executed? = activity.executing + activity.finished > 0
started = if executed?, do: assigns.workflow.started_at
assigns = assign(assigns, started: started)
~H"""
<span
:if={@started}
class="w-24 text-right text-sm"
id={"wf-started-#{@workflow.id}"}
data-timestamp={DateTime.to_unix(@started, :millisecond)}
phx-hook="Relativize"
phx-update="ignore"
>
-
</span>
<span :if={is_nil(@started)} class="w-24 text-right text-sm">
-
</span>
"""
end
attr :workflow, :map, required: true
defp format_duration(assigns) do
wf = assigns.workflow
executing? = wf.state == :executing
started? = not is_nil(wf.started_at)
duration =
if wf.started_at && wf.completed_at do
DateTime.diff(wf.completed_at, wf.started_at, :millisecond)
end
formatted =
if is_nil(duration) or duration <= 0 do
"-"
else
duration |> div(1000) |> Timing.to_duration()
end
assigns =
assign(assigns,
executing?: executing?,
started?: started?,
formatted: formatted,
started_at: wf.started_at
)
~H"""
<span
:if={@executing? and @started?}
class="w-24 text-right text-sm"
id={"wf-duration-#{@workflow.id}"}
data-timestamp={DateTime.to_unix(@started_at, :millisecond)}
data-relative-mode="duration"
phx-hook="Relativize"
phx-update="ignore"
>
-
</span>
<span :if={not (@executing? and @started?)} class="w-24 text-right text-sm">
{@formatted}
</span>
"""
end
attr :id, :string, required: true
attr :state, :atom, required: true
defp status_indicator(assigns) do
~H"""
<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" />
<% :completed -> %>
<Icons.check_circle class="w-5 h-5 text-cyan-400" />
<% :cancelled -> %>
<Icons.x_circle class="w-5 h-5 text-violet-400" />
<% :discarded -> %>
<Icons.exclamation_circle class="w-5 h-5 text-rose-400" />
<% _ -> %>
<Icons.minus_circle class="w-5 h-5 text-gray-400" />
<% end %>
</span>
"""
end
defp status_title(:executing), do: "Executing"
defp status_title(:completed), do: "Completed"
defp status_title(:cancelled), do: "Cancelled"
defp status_title(:discarded), do: "Discarded"
defp status_title(_), do: "Unknown"
end

83
vendor/oban_web/lib/oban/web/metrics.ex vendored Normal file
View file

@ -0,0 +1,83 @@
defmodule Oban.Web.Metrics do
@moduledoc false
alias Oban.Met
@states ~w(available executing scheduled retryable)a
@doc """
Fetch latest metric counts with optional fallback to previous values.
When `Met.latest/3` returns an empty map (which happens when the reporter's
`check_interval` exceeds the recorder's lookback window), this function
returns the `previous` value instead. This prevents UI flickering in the
sidebar when metrics briefly show as zero between reporter broadcasts.
"""
def latest(name, series, opts \\ [], previous \\ %{}) do
result = Met.latest(name, series, opts)
if result == %{} and previous != %{} do
previous
else
result
end
end
@doc """
Fetch state counts for the jobs sidebar.
"""
def state_counts(name, ordered_states, previous \\ []) do
counts = latest(name, :full_count, [group: "state"], counts_to_map(previous))
if counts == %{} and previous != [] do
previous
else
for state <- ordered_states do
%{name: state, count: Map.get(counts, state, 0)}
end
end
end
defp counts_to_map(previous) when is_list(previous) do
Map.new(previous, fn %{name: name, count: count} -> {name, count} end)
end
defp counts_to_map(previous), do: previous
@doc """
Fetch queue counts for a specific state.
"""
def queue_counts(name, state, previous \\ %{}) do
latest(name, :full_count, [group: "queue", filters: [state: to_string(state)]], previous)
end
@doc """
Fetch state counts filtered by queue for queue detail view.
"""
def queue_state_counts(name, queue, previous \\ %{}) do
latest(name, :full_count, [group: "state", filters: [queue: queue]], previous)
end
@doc """
Fetch counts for all queue states needed for the queues sidebar.
"""
def all_queue_counts(name, previous \\ %{}) do
Map.new(@states, fn state ->
{state, queue_counts(name, state, previous[state])}
end)
end
@doc """
Extract previous counts from a list of queue structs for caching.
"""
def extract_queue_counts(queues) when is_list(queues) do
for state <- @states, into: %{} do
per_queue =
for queue <- queues, into: %{}, do: {queue.name, Map.get(queue.counts, state, 0)}
{state, per_queue}
end
end
def extract_queue_counts(_), do: %{}
end

View file

@ -0,0 +1,264 @@
defmodule Oban.Web.CronsPage do
@behaviour Oban.Web.Page
use Oban.Web, :live_component
alias Oban.Web.{Cron, CronQuery, Page, QueueQuery, SearchComponent, SortComponent, Utils}
alias Oban.Web.Crons.{DetailComponent, NewComponent, TableComponent}
@known_params ~w(limit modes names sort_by sort_dir states workers)
@inc_limit 20
@max_limit 100
@min_limit 20
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div id="crons-page" class="w-full flex flex-col my-6 md:flex-row">
<div class="bg-white dark:bg-gray-900 flex-grow rounded-md shadow-lg overflow-hidden">
<%= if @detailed do %>
<.live_component
id="detail"
access={@access}
conf={@conf}
cron={@detailed}
history={@detailed.history}
module={DetailComponent}
params={without_defaults(Map.delete(@params, "id"), @default_params)}
queues={QueueQuery.all_queues(%{}, @conf)}
resolver={@resolver}
/>
<% else %>
<div
id="crons-header"
class="pr-3 py-3 flex items-center border-b border-gray-200 dark:border-gray-700"
>
<div class="flex-none flex items-center px-3">
<h2 class="text-lg dark:text-gray-200 leading-4 font-bold">Crons</h2>
</div>
<.live_component
conf={@conf}
id="search"
module={SearchComponent}
page={:crons}
params={without_defaults(@params, @default_params)}
queryable={CronQuery}
resolver={@resolver}
/>
<div class="pl-3 ml-auto flex items-center">
<SortComponent.select
id="crons-sort"
by={~w(name worker schedule last_run next_run)}
page={:crons}
params={@params}
/>
<.link
:if={Utils.has_crons?(@conf)}
patch={can?(:insert_crons, @access) && oban_path([:crons, :new])}
id="new-cron-button"
data-title="Create a new dynamic cron"
phx-hook="Tippy"
aria-disabled={not can?(:insert_crons, @access)}
class={[
"ml-3 h-10 flex items-center text-sm bg-white dark:bg-gray-800 px-3 py-2 border rounded-md",
can?(:insert_crons, @access) &&
"text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-700 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 hover:text-blue-500 hover:border-blue-600 cursor-pointer",
not can?(:insert_crons, @access) &&
"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
</.link>
</div>
</div>
<.live_component id="crons-table" module={TableComponent} crontab={@crontab} />
<div
:if={@show_less? or @show_more?}
class="py-6 flex items-center justify-center space-x-6"
>
<.load_button label="Show Less" click="load-less" active={@show_less?} myself={@myself} />
<.load_button label="Show More" click="load-more" active={@show_more?} myself={@myself} />
</div>
<% end %>
</div>
<.live_component
:if={@show_new_form}
id="new-cron-form"
access={@access}
conf={@conf}
module={NewComponent}
queues={QueueQuery.all_queues(%{}, @conf)}
/>
</div>
"""
end
# Components
attr :active, :boolean, required: true
attr :click, :string, required: true
attr :label, :string, required: true
attr :myself, :any, required: true
defp load_button(assigns) do
~H"""
<button
type="button"
class={"font-semibold text-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 #{loader_class(@active)}"}
phx-target={@myself}
phx-click={@click}
>
{@label}
</button>
"""
end
defp loader_class(true) do
"""
text-gray-700 dark:text-gray-300 cursor-pointer transition ease-in-out duration-200 border-b
border-gray-200 dark:border-gray-800 hover:border-gray-400
"""
end
defp loader_class(_), do: "text-gray-400 dark:text-gray-500 cursor-not-allowed"
@impl Page
def handle_mount(socket) do
default = %{limit: @min_limit, sort_by: "worker", sort_dir: "asc"}
socket
|> assign(:default_params, default)
|> assign_new(:detailed, fn -> nil end)
|> assign_new(:params, fn -> default end)
|> assign_new(:crontab, fn -> [] end)
|> assign_new(:show_less?, fn -> false end)
|> assign_new(:show_more?, fn -> false end)
end
@impl Phoenix.LiveComponent
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:show_new_form, fn -> false end)
{:ok, socket}
end
@impl Page
def handle_refresh(%{assigns: %{detailed: nil}} = socket) do
%{params: params, conf: conf} = socket.assigns
crons = CronQuery.all_crons(params, conf)
limit = params.limit
assign(socket,
crontab: crons,
show_less?: limit > @min_limit,
show_more?: limit < @max_limit and length(crons) == limit
)
end
def handle_refresh(socket) do
%{conf: conf, detailed: detailed} = socket.assigns
case detailed do
%Cron{name: name} -> assign(socket, detailed: CronQuery.get_cron(name, conf))
_ -> socket
end
end
@impl Page
def handle_params(%{"id" => "new"} = params, _uri, socket) do
params =
params
|> Map.take(@known_params)
|> decode_params()
|> then(&Map.merge(socket.assigns.default_params, &1))
{:noreply,
socket
|> assign(detailed: nil, show_new_form: true, page_title: page_title("New Cron"))
|> assign(params: params)}
end
def handle_params(%{"id" => cron_name} = params, _uri, socket) do
params =
params
|> Map.take(@known_params)
|> decode_params()
|> then(&Map.merge(socket.assigns.default_params, &1))
case CronQuery.get_cron(cron_name, socket.assigns.conf) do
nil ->
{:noreply, push_patch(socket, to: oban_path(:crons), replace: true)}
cron ->
{:noreply,
socket
|> assign(detailed: cron, show_new_form: false, page_title: page_title(cron.worker))
|> assign(params: params)}
end
end
def handle_params(params, _uri, socket) do
params =
params
|> Map.take(@known_params)
|> decode_params()
socket =
socket
|> assign(detailed: nil, show_new_form: false, page_title: page_title("Crons"))
|> assign(params: Map.merge(socket.assigns.default_params, params))
|> handle_refresh()
{:noreply, socket}
end
@impl Phoenix.LiveComponent
def handle_event("load-less", _params, socket) do
if socket.assigns.show_less? do
send(self(), {:params, :limit, -@inc_limit})
end
{:noreply, socket}
end
def handle_event("load-more", _params, socket) do
if socket.assigns.show_more? do
send(self(), {:params, :limit, @inc_limit})
end
{:noreply, socket}
end
@impl Page
def handle_info({:params, :limit, inc}, socket) when is_integer(inc) do
params =
socket.assigns.params
|> Map.update!(:limit, &(&1 + inc))
|> without_defaults(socket.assigns.default_params)
{:noreply, push_patch(socket, to: oban_path(:crons, params), replace: true)}
end
def handle_info({:flash, mode, message}, socket) do
{:noreply, put_flash_with_clear(socket, mode, message)}
end
def handle_info(:refresh, socket) do
{:noreply, handle_refresh(socket)}
end
def handle_info(_event, socket) do
{:noreply, socket}
end
end

View file

@ -4,16 +4,21 @@ defmodule Oban.Web.JobsPage do
use Oban.Web, :live_component use Oban.Web, :live_component
alias Oban.Met alias Oban.Met
alias Oban.Web.Jobs.{ChartComponent, DetailComponent} alias Oban.Web.{JobQuery, Metrics, Page, QueueQuery, SearchComponent, SortComponent, Telemetry}
alias Oban.Web.Jobs.{ChartComponent, DetailComponent, NewComponent}
alias Oban.Web.Jobs.{SidebarComponent, TableComponent} alias Oban.Web.Jobs.{SidebarComponent, TableComponent}
alias Oban.Web.{JobQuery, Page, QueueQuery, SearchComponent, SortComponent, Telemetry}
@known_params ~w(args ids limit meta nodes priorities queues sort_by sort_dir state tags workers) @known_params ~w(args ids limit meta nodes priorities queues sort_by sort_dir state tags workers)
@ordered_states ~w(executing available scheduled retryable cancelled discarded completed) @ordered_states ~w(executing available scheduled suspended retryable cancelled discarded completed)
@impl Phoenix.LiveComponent @impl Phoenix.LiveComponent
def update(assigns, socket) do def update(assigns, socket) do
{:ok, assign(socket, assigns)} socket =
socket
|> assign(assigns)
|> assign_new(:show_new_form, fn -> false end)
{:ok, socket}
end end
@impl Phoenix.LiveComponent @impl Phoenix.LiveComponent
@ -21,6 +26,7 @@ defmodule Oban.Web.JobsPage do
~H""" ~H"""
<div id="jobs-page" class="flex-1 w-full flex flex-col my-6 md:flex-row"> <div id="jobs-page" class="flex-1 w-full flex flex-col my-6 md:flex-row">
<SidebarComponent.sidebar <SidebarComponent.sidebar
:if={is_nil(@detailed)}
nodes={@nodes} nodes={@nodes}
params={without_defaults(@params, @default_params)} params={without_defaults(@params, @default_params)}
queues={@queues} queues={@queues}
@ -40,15 +46,24 @@ defmodule Oban.Web.JobsPage do
params={@params} params={@params}
/> />
<div class="bg-white dark:bg-gray-900 rounded-md shadow-lg"> <div class={[
"bg-white dark:bg-gray-900 rounded-md shadow-lg",
@detailed && "mx-4"
]}>
<%= if @detailed do %> <%= if @detailed do %>
<.live_component <.live_component
id="detail" id="detail"
access={@access} access={@access}
conf={@conf}
diagnostics={@diagnostics}
diagnostics_at={@diagnostics_at}
history={@history}
init_state={@init_state}
job={@detailed} job={@detailed}
module={DetailComponent} module={DetailComponent}
os_time={@os_time} os_time={@os_time}
params={without_defaults(Map.delete(@params, "id"), @default_params)} params={without_defaults(Map.delete(@params, "id"), @default_params)}
queues={@queues}
resolver={@resolver} resolver={@resolver}
/> />
<% else %> <% else %>
@ -121,7 +136,7 @@ defmodule Oban.Web.JobsPage do
resolver={@resolver} resolver={@resolver}
/> />
<div class="pl-3 ml-auto"> <div class="pl-3 ml-auto flex items-center">
<span :if={Enum.any?(@selected)} class="block py-2 text-sm font-semibold"> <span :if={Enum.any?(@selected)} class="block py-2 text-sm font-semibold">
{MapSet.size(@selected)} Selected {MapSet.size(@selected)} Selected
</span> </span>
@ -131,6 +146,24 @@ defmodule Oban.Web.JobsPage do
params={@params} params={@params}
by={~w(time attempt queue worker)} by={~w(time attempt queue worker)}
/> />
<.link
:if={Enum.empty?(@selected)}
patch={can?(:insert_jobs, @access) && oban_path([:jobs, :new])}
id="new-job-button"
data-title="Create a new job"
phx-hook="Tippy"
aria-disabled={not can?(:insert_jobs, @access)}
class={[
"ml-3 h-10 flex items-center text-sm bg-white dark:bg-gray-800 px-3 py-2 border rounded-md",
can?(:insert_jobs, @access) &&
"text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-700 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 hover:text-blue-500 hover:border-blue-600 cursor-pointer",
not can?(:insert_jobs, @access) &&
"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
</.link>
</div> </div>
</div> </div>
@ -146,6 +179,15 @@ defmodule Oban.Web.JobsPage do
<% end %> <% end %>
</div> </div>
</div> </div>
<.live_component
:if={@show_new_form}
id="new-job-form"
access={@access}
conf={@conf}
module={NewComponent}
queues={@queues}
/>
</div> </div>
""" """
end end
@ -163,6 +205,9 @@ defmodule Oban.Web.JobsPage do
%{socket | assigns: assigns} %{socket | assigns: assigns}
|> assign_new(:default_params, default) |> assign_new(:default_params, default)
|> assign_new(:detailed, fn -> nil end) |> assign_new(:detailed, fn -> nil end)
|> assign_new(:diagnostics, fn -> nil end)
|> assign_new(:diagnostics_at, fn -> nil end)
|> assign_new(:history, fn -> [] end)
|> assign_new(:jobs, fn -> [] end) |> assign_new(:jobs, fn -> [] end)
|> assign_new(:nodes, fn -> [] end) |> assign_new(:nodes, fn -> [] end)
|> assign_new(:os_time, fn -> System.os_time(:second) end) |> assign_new(:os_time, fn -> System.os_time(:second) end)
@ -189,29 +234,68 @@ defmodule Oban.Web.JobsPage do
MapSet.new() MapSet.new()
end end
detailed = JobQuery.refresh_job(conf, socket.assigns.detailed)
history =
if detailed do
JobQuery.job_history(detailed, conf)
else
[]
end
# Request fresh diagnostics if executing, but preserve existing data when job stops
diagnostics =
if detailed && detailed.state == "executing" do
Oban.Notifier.notify(conf.name, :diagnostics, %{job_id: detailed.id})
socket.assigns.diagnostics
else
socket.assigns.diagnostics
end
diagnostics_at = socket.assigns.diagnostics_at
assign(socket, assign(socket,
detailed: JobQuery.refresh_job(conf, socket.assigns.detailed), detailed: detailed,
diagnostics: diagnostics,
diagnostics_at: diagnostics_at,
history: history,
jobs: jobs, jobs: jobs,
nodes: nodes(conf), nodes: nodes(conf),
os_time: System.os_time(:second), os_time: System.os_time(:second),
queues: queues(conf), queues: queues(conf, socket.assigns.queues),
selected: selected, selected: selected,
states: states(conf) states: states(conf, socket.assigns.states)
) )
end end
@impl Page @impl Page
def handle_params(%{"id" => job_id} = params, _uri, socket) do def handle_params(%{"id" => "new"} = params, _uri, socket) do
params = params_with_defaults(params, socket) params = params_with_defaults(params, socket)
case JobQuery.refresh_job(socket.assigns.conf, job_id) do {:noreply,
socket
|> assign(detailed: nil, show_new_form: true, page_title: page_title("New Job"))
|> assign(params: params)}
end
def handle_params(%{"id" => job_id} = params, _uri, socket) do
params = params_with_defaults(params, socket)
conf = socket.assigns.conf
case JobQuery.refresh_job(conf, job_id) do
nil -> nil ->
{:noreply, push_patch(socket, to: oban_path(:jobs), replace: true)} {:noreply, push_patch(socket, to: oban_path(:jobs), replace: true)}
job -> job ->
Oban.Notifier.listen(conf.name, [:diagnostics_reply])
history = JobQuery.job_history(job, conf)
{:noreply, {:noreply,
socket socket
|> assign(detailed: job, page_title: page_title(job)) |> assign(detailed: job, show_new_form: false, page_title: page_title(job))
|> assign(diagnostics: nil, diagnostics_at: nil)
|> assign(history: history)
|> assign(params: params)} |> assign(params: params)}
end end
end end
@ -219,14 +303,22 @@ defmodule Oban.Web.JobsPage do
def handle_params(params, _uri, socket) do def handle_params(params, _uri, socket) do
%{conf: conf, resolver: resolver} = socket.assigns %{conf: conf, resolver: resolver} = socket.assigns
Oban.Notifier.unlisten(conf.name, [:diagnostics_reply])
params = params_with_defaults(params, socket) params = params_with_defaults(params, socket)
socket = socket =
socket socket
|> assign(detailed: nil, page_title: page_title("Jobs")) |> assign(detailed: nil, show_new_form: false, page_title: page_title("Jobs"))
|> assign(diagnostics: nil, diagnostics_at: nil)
|> assign(history: [])
|> assign(params: params) |> assign(params: params)
|> assign(jobs: JobQuery.all_jobs(params, conf, resolver: resolver)) |> assign(jobs: JobQuery.all_jobs(params, conf, resolver: resolver))
|> assign(nodes: nodes(conf), queues: queues(conf), states: states(conf)) |> assign(nodes: nodes(conf))
|> assign(
queues: queues(conf, socket.assigns.queues),
states: states(conf, socket.assigns.states)
)
{:noreply, socket} {:noreply, socket}
end end
@ -281,6 +373,20 @@ defmodule Oban.Web.JobsPage do
{:noreply, socket} {:noreply, socket}
end end
def handle_info({:flash, mode, message}, socket) do
{:noreply, put_flash_with_clear(socket, mode, message)}
end
# Diagnostics
def handle_info({:notification, :diagnostics_reply, %{"job_id" => job_id} = payload}, socket) do
if socket.assigns.detailed && socket.assigns.detailed.id == job_id do
{:noreply, assign(socket, diagnostics: payload, diagnostics_at: System.os_time(:second))}
else
{:noreply, socket}
end
end
# Filtering # Filtering
def handle_info({:params, :limit, inc}, socket) when is_integer(inc) do def handle_info({:params, :limit, inc}, socket) when is_integer(inc) do
@ -322,6 +428,23 @@ defmodule Oban.Web.JobsPage do
{:noreply, assign(socket, detailed: job)} {:noreply, assign(socket, detailed: job)}
end end
def handle_info({:update_job, job, changes}, socket) do
conf = socket.assigns.conf
case Oban.update_job(conf.name, job.id, changes) do
{:ok, updated_job} ->
socket =
socket
|> put_flash_with_clear(:info, "Job updated successfully")
|> assign(detailed: updated_job)
{:noreply, socket}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to update job")}
end
end
# Selection # Selection
def handle_info({:toggle_select, job_id}, socket) do def handle_info({:toggle_select, job_id}, socket) do
@ -466,15 +589,14 @@ defmodule Oban.Web.JobsPage do
|> Enum.sort_by(& &1.name) |> Enum.sort_by(& &1.name)
end end
def states(conf) do defp states(conf, previous) do
counts = Met.latest(conf.name, :full_count, group: "state") Metrics.state_counts(conf.name, @ordered_states, previous)
for state <- @ordered_states do
%{name: state, count: Map.get(counts, state, 0)}
end
end end
def queues(conf) do defp queues(conf, previous) do
QueueQuery.all_queues(%{}, conf) previous_counts = Metrics.extract_queue_counts(previous)
counts = Metrics.all_queue_counts(conf.name, previous_counts)
QueueQuery.all_queues(%{}, conf, counts)
end end
end end

View file

@ -4,25 +4,21 @@ defmodule Oban.Web.QueuesPage do
use Oban.Web, :live_component use Oban.Web, :live_component
alias Oban.Met alias Oban.Met
alias Oban.Web.{Metrics, Page, QueueQuery, SearchComponent, SortComponent, Telemetry, Timing}
alias Oban.Web.Queues.{DetailComponent, DetailInstanceComponent, TableComponent}
alias Oban.Web.Queues.{DetailComponent, DetailInsanceComponent} @inc_limit 20
alias Oban.Web.Queues.{SidebarComponent, TableComponent} @max_limit 100
alias Oban.Web.{Page, QueueQuery, SearchComponent, SortComponent, Telemetry} @min_limit 20
@known_params ~w(modes nodes sort_by sort_dir stats) @known_params ~w(limit modes nodes sort_by sort_dir stats)
@keep_on_mount ~w(checks counts default_params detail history node_history params queues selected)a
@impl Phoenix.LiveComponent @impl Phoenix.LiveComponent
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div id="queues-page" class="w-full flex flex-col my-6 md:flex-row"> <div id="queues-page" class="w-full my-6">
<SidebarComponent.sidebar <div>
queues={@queues}
params={without_defaults(@params, @default_params)}
width={@sidebar_width}
csp_nonces={@csp_nonces}
/>
<div class="flex-grow">
<div class="bg-white dark:bg-gray-900 rounded-md shadow-lg overflow-hidden"> <div class="bg-white dark:bg-gray-900 rounded-md shadow-lg overflow-hidden">
<%= if @detail do %> <%= if @detail do %>
<.live_component <.live_component
@ -31,6 +27,7 @@ defmodule Oban.Web.QueuesPage do
conf={@conf} conf={@conf}
checks={@checks} checks={@checks}
module={DetailComponent} module={DetailComponent}
node_history={@node_history}
queue={@detail} queue={@detail}
/> />
<% else %> <% else %>
@ -110,7 +107,7 @@ defmodule Oban.Web.QueuesPage do
id="queues-sort" id="queues-sort"
by={~w(name nodes avail exec local global rate_limit started)} by={~w(name nodes avail exec local global rate_limit started)}
page={:queues} page={:queues}
params={@params} params={sort_params(@params, @default_params)}
/> />
</div> </div>
</div> </div>
@ -119,10 +116,20 @@ defmodule Oban.Web.QueuesPage do
id="queues-table" id="queues-table"
module={TableComponent} module={TableComponent}
access={@access} access={@access}
checks={@checks}
history={@history}
params={@params} params={@params}
queues={@queues} queues={@queues}
selected={@selected} selected={@selected}
/> />
<div
:if={@show_less? or @show_more?}
class="py-6 flex items-center justify-center space-x-6 border-t border-gray-200 dark:border-gray-700"
>
<.load_button label="Show Less" click="load-less" active={@show_less?} myself={@myself} />
<.load_button label="Show More" click="load-more" active={@show_more?} myself={@myself} />
</div>
<% end %> <% end %>
</div> </div>
</div> </div>
@ -130,39 +137,113 @@ defmodule Oban.Web.QueuesPage do
""" """
end end
@keep_on_mount ~w(checks counts default_params detail params queues selected)a
@impl Page @impl Page
def handle_mount(socket) do def handle_mount(socket) do
default = fn -> %{sort_by: "name", sort_dir: "asc"} end default = fn -> %{limit: @min_limit, sort_by: "name", sort_dir: "asc"} end
assigns = assigns =
Map.drop(socket.assigns, @keep_on_mount) Map.drop(socket.assigns, @keep_on_mount)
%{socket | assigns: assigns} %{socket | assigns: assigns}
|> assign_new(:checks, fn -> checks(socket.assigns.conf) end) |> assign_new(:checks, fn -> checks(socket.assigns.conf) end)
|> assign_new(:counts, fn -> counts(socket.assigns.conf) end) |> assign_new(:counts, fn -> %{} end)
|> assign_new(:default_params, default) |> assign_new(:default_params, default)
|> assign_new(:detail, fn -> nil end) |> assign_new(:detail, fn -> nil end)
|> assign_new(:history, fn -> %{} end)
|> assign_new(:node_history, fn -> %{} end)
|> assign_new(:params, default) |> assign_new(:params, default)
|> assign_new(:queues, fn -> [] end) |> assign_new(:queues, fn -> [] end)
|> assign_new(:selected, &MapSet.new/0) |> assign_new(:selected, &MapSet.new/0)
|> assign_new(:show_less?, fn -> false end)
|> assign_new(:show_more?, fn -> false end)
end end
@impl Page @impl Page
def handle_refresh(socket) do def handle_refresh(socket) do
conf = socket.assigns.conf conf = socket.assigns.conf
queues = QueueQuery.all_queues(socket.assigns.params, conf) params = socket.assigns.params
limit = params[:limit] || @min_limit
assign(socket, counts: counts(conf), checks: checks(conf), queues: queues) previous_counts = Metrics.extract_queue_counts(socket.assigns.queues)
queue_counts = Metrics.all_queue_counts(conf.name, previous_counts)
queues = QueueQuery.all_queues(params, conf, queue_counts)
node_history =
if socket.assigns.detail do
node_history(conf, socket.assigns.detail)
else
%{}
end
assign(socket,
checks: checks(conf),
counts: queue_counts[:available],
history: queue_history(conf),
node_history: node_history,
queues: queues,
show_less?: limit > @min_limit,
show_more?: limit < @max_limit and length(queues) == limit
)
end end
defp checks(conf) do defp checks(conf) do
Met.checks(conf.name) Met.checks(conf.name)
end end
defp counts(conf) do defp queue_history(conf) do
Met.latest(conf.name, :full_count, group: "queue", filters: [state: "available"]) transform_history(conf, group: "queue", lookback: 300)
end
defp node_history(conf, queue) do
transform_history(conf, group: "node", lookback: 600, filters: [queue: queue])
end
defp transform_history(conf, opts) do
by = 5
since = Timing.snap(System.system_time(:second), by)
opts = Keyword.merge(opts, by: by, since: since)
conf.name
|> Met.timeslice(:exec_count, opts)
|> Enum.group_by(&elem(&1, 2), fn {index, count, _group} ->
timestamp = (since - index * 5) * 1000
{index, %{count: count, timestamp: timestamp}}
end)
|> Map.new(fn {group, data} -> {group, Map.new(data)} end)
end
attr :active, :boolean, required: true
attr :click, :string, required: true
attr :label, :string, required: true
attr :myself, :any, required: true
defp load_button(assigns) do
~H"""
<button
type="button"
class={"font-semibold text-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 #{loader_class(@active)}"}
phx-target={@myself}
phx-click={@click}
>
{@label}
</button>
"""
end
defp loader_class(true) do
"""
text-gray-700 dark:text-gray-300 cursor-pointer transition ease-in-out duration-200 border-b
border-gray-200 dark:border-gray-800 hover:border-gray-400
"""
end
defp loader_class(_), do: "text-gray-400 dark:text-gray-500 cursor-not-allowed"
defp sort_params(params, default_params) do
params
|> without_defaults(default_params)
|> Map.merge(Map.take(params, [:sort_by, :sort_dir]))
end end
defp select_mode(checks, selected) do defp select_mode(checks, selected) do
@ -228,6 +309,22 @@ defmodule Oban.Web.QueuesPage do
{:noreply, socket} {:noreply, socket}
end end
def handle_event("load-less", _params, socket) do
if socket.assigns.show_less? do
send(self(), {:params, :limit, -@inc_limit})
end
{:noreply, socket}
end
def handle_event("load-more", _params, socket) do
if socket.assigns.show_more? do
send(self(), {:params, :limit, @inc_limit})
end
{:noreply, socket}
end
@impl Page @impl Page
def handle_info({:pause_queue, queue}, socket) do def handle_info({:pause_queue, queue}, socket) do
Telemetry.action(:pause_queue, socket, [queue: queue], fn -> Telemetry.action(:pause_queue, socket, [queue: queue], fn ->
@ -312,6 +409,16 @@ defmodule Oban.Web.QueuesPage do
{:noreply, socket} {:noreply, socket}
end end
def handle_info({:stop_queue, queue}, socket) do
enforce_access!(:stop_queues, socket.assigns.access)
Telemetry.action(:stop_queue, socket, [queue: queue], fn ->
Oban.stop_queue(socket.assigns.conf.name, queue: queue)
end)
{:noreply, put_flash_with_clear(socket, :info, "Queue #{queue} stopped")}
end
def handle_info({:toggle_select, queue}, socket) do def handle_info({:toggle_select, queue}, socket) do
selected = socket.assigns.selected selected = socket.assigns.selected
@ -360,13 +467,22 @@ defmodule Oban.Web.QueuesPage do
if Keyword.has_key?(opts, :limit) do if Keyword.has_key?(opts, :limit) do
for checks <- socket.assigns.checks do for checks <- socket.assigns.checks do
send_update(DetailInsanceComponent, id: node_name(checks), local_limit: opts[:limit]) send_update(DetailInstanceComponent, id: node_name(checks), local_limit: opts[:limit])
end end
end end
{:noreply, put_flash_with_clear(socket, :info, scale_message(queue, opts))} {:noreply, put_flash_with_clear(socket, :info, scale_message(queue, opts))}
end end
def handle_info({:params, :limit, inc}, socket) when is_integer(inc) do
params =
socket.assigns.params
|> Map.update!(:limit, &(&1 + inc))
|> without_defaults(socket.assigns.default_params)
{:noreply, push_patch(socket, to: oban_path(:queues, params), replace: true)}
end
def handle_info(_, socket) do def handle_info(_, socket) do
{:noreply, socket} {:noreply, socket}
end end

View file

@ -0,0 +1,336 @@
defmodule Oban.Web.WorkflowsPage do
@behaviour Oban.Web.Page
use Oban.Web, :live_component
alias Oban.Pro.Workflow
alias Oban.Web.{Page, SearchComponent, SortComponent, Telemetry, Utils, WorkflowQuery}
alias Oban.Web.Workflows.{DetailComponent, TableComponent}
@compile {:no_warn_undefined, Oban.Pro.Workflow}
@known_params ~w(ids limit names queues sort_by sort_dir states workers)
@keep_on_mount ~w(
default_params
detail
sub_workflows
graph_data
params
parent_workflow
workflow
workflows
)a
@inc_limit 20
@max_limit 100
@min_limit 20
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div id="workflows-page" class="w-full my-6">
<div class="bg-white dark:bg-gray-900 rounded-md shadow-lg overflow-hidden">
<%= cond do %>
<% not @has_workflows? -> %>
<.pro_promo />
<% @detail -> %>
<.live_component
id="detail"
access={@access}
conf={@conf}
module={DetailComponent}
pro_available?={@pro_available?}
workflow={@workflow}
parent_workflow={@parent_workflow}
sub_workflows={@sub_workflows}
graph_data={@graph_data}
/>
<% true -> %>
<div
id="workflows-header"
class="pr-3 py-3 flex items-center border-b border-gray-200 dark:border-gray-700"
>
<div class="flex-none flex items-center px-3">
<h2 class="text-lg dark:text-gray-200 leading-4 font-bold">Workflows</h2>
</div>
<.live_component
conf={@conf}
id="search"
module={SearchComponent}
page={:workflows}
params={without_defaults(@params, @default_params)}
queryable={WorkflowQuery}
resolver={@resolver}
/>
<div class="pl-3 ml-auto flex items-center">
<SortComponent.select
id="workflows-sort"
by={~w(inserted started duration total progress)}
page={:workflows}
params={@params}
/>
</div>
</div>
<.live_component id="workflows-table" module={TableComponent} workflows={@workflows} />
<div
:if={@show_less? or @show_more?}
class="py-6 flex items-center justify-center space-x-6 border-t border-gray-200 dark:border-gray-700"
>
<.load_button label="Show Less" click="load-less" active={@show_less?} myself={@myself} />
<.load_button label="Show More" click="load-more" active={@show_more?} myself={@myself} />
</div>
<% end %>
</div>
</div>
"""
end
attr :active, :boolean, required: true
attr :click, :string, required: true
attr :label, :string, required: true
attr :myself, :any, required: true
defp load_button(assigns) do
~H"""
<button
type="button"
class={"font-semibold text-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 #{loader_class(@active)}"}
phx-target={@myself}
phx-click={@click}
>
{@label}
</button>
"""
end
defp loader_class(true) do
"""
text-gray-700 dark:text-gray-300 cursor-pointer transition ease-in-out duration-200 border-b
border-gray-200 dark:border-gray-800 hover:border-gray-400
"""
end
defp loader_class(_), do: "text-gray-400 dark:text-gray-500 cursor-not-allowed"
defp pro_promo(assigns) 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" />
</div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3">
Workflows
</h2>
<p class="text-center text-gray-600 dark:text-gray-400 max-w-3xl mb-6">
Orchestrate jobs with dependencies for sequential execution, fan-out parallelization, and
fan-in convergence. Build fault-tolerant processing pipelines that scale horizontally
across all nodes.
</p>
<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" />
<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" />
<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" />
<span>
<span class="font-medium text-gray-700 dark:text-gray-300">Nested Sub-Workflows</span>
compose hierarchically for better organization and reusability
</span>
</li>
</ul>
<.link
href="https://oban.pro"
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" />
</.link>
</div>
"""
end
@impl Page
def handle_mount(socket) do
default = %{limit: @min_limit, sort_by: "inserted", sort_dir: "desc"}
assigns = Map.drop(socket.assigns, @keep_on_mount)
%{socket | assigns: assigns}
|> assign(:default_params, default)
|> assign(:has_workflows?, Utils.has_workflows?(socket.assigns.conf))
|> assign(:pro_available?, Utils.has_pro?())
|> assign_new(:detail, fn -> nil end)
|> assign_new(:sub_workflows, fn -> [] end)
|> assign_new(:params, fn -> default end)
|> assign_new(:parent_workflow, fn -> nil end)
|> assign_new(:show_less?, fn -> false end)
|> assign_new(:show_more?, fn -> false end)
|> assign_new(:workflow, fn -> nil end)
|> assign_new(:workflows, fn -> [] end)
end
@impl Page
def handle_refresh(socket) do
%{params: params, conf: conf, detail: detail, has_workflows?: has_workflows?} = socket.assigns
cond do
not has_workflows? ->
socket
detail ->
workflow = WorkflowQuery.get_workflow(conf, detail)
sub_workflows = WorkflowQuery.get_sub_workflows(conf, detail)
parent_workflow = WorkflowQuery.get_sup_workflow(conf, detail)
graph_data = WorkflowQuery.get_workflow_graph(conf, detail)
assign(socket,
workflow: workflow,
sub_workflows: sub_workflows,
parent_workflow: parent_workflow,
graph_data: graph_data
)
true ->
workflows = WorkflowQuery.all_workflows(params, conf)
limit = params.limit
assign(socket,
workflows: workflows,
show_less?: limit > @min_limit,
show_more?: limit < @max_limit and length(workflows) == limit
)
end
end
@impl Page
def handle_params(%{"id" => workflow_id}, _uri, socket) do
conf = socket.assigns.conf
workflow = WorkflowQuery.get_workflow(conf, workflow_id)
sub_workflows = WorkflowQuery.get_sub_workflows(conf, workflow_id)
parent_workflow = WorkflowQuery.get_sup_workflow(conf, workflow_id)
graph_data = WorkflowQuery.get_workflow_graph(conf, workflow_id)
title = if workflow, do: workflow.name || workflow_id, else: "Workflow"
socket =
assign(socket,
detail: workflow_id,
workflow: workflow,
sub_workflows: sub_workflows,
parent_workflow: parent_workflow,
graph_data: graph_data,
page_title: page_title(title)
)
{:noreply, socket}
end
def handle_params(params, _uri, socket) do
params =
params
|> Map.take(@known_params)
|> decode_params()
socket =
socket
|> assign(page_title: page_title("Workflows"))
|> assign(detail: nil, params: Map.merge(socket.assigns.default_params, params))
|> handle_refresh()
{:noreply, socket}
end
@impl Phoenix.LiveComponent
def handle_event("load-less", _params, socket) do
if socket.assigns.show_less? do
send(self(), {:params, :limit, -@inc_limit})
end
{:noreply, socket}
end
def handle_event("load-more", _params, socket) do
if socket.assigns.show_more? do
send(self(), {:params, :limit, @inc_limit})
end
{:noreply, socket}
end
@impl Page
def handle_info({:params, :limit, inc}, socket) when is_integer(inc) do
params =
socket.assigns.params
|> Map.update!(:limit, &(&1 + inc))
|> without_defaults(socket.assigns.default_params)
{:noreply, push_patch(socket, to: oban_path(:workflows, params), replace: true)}
end
def handle_info(:refresh, socket) do
{:noreply, handle_refresh(socket)}
end
def handle_info({:cancel_workflow, workflow_id}, socket) do
enforce_access!(:cancel_workflows, socket.assigns.access)
socket =
if Utils.has_pro?() do
Telemetry.action(:cancel_workflow, socket, [workflow_id: workflow_id], fn ->
Workflow.cancel_jobs(socket.assigns.conf.name, workflow_id)
end)
socket
|> handle_refresh()
|> put_flash_with_clear(:info, "Workflow jobs cancelled")
else
put_flash_with_clear(socket, :error, "Cancel requires Oban Pro")
end
{:noreply, socket}
end
def handle_info({:retry_workflow, workflow_id}, socket) do
enforce_access!(:retry_workflows, socket.assigns.access)
socket =
if Utils.has_pro?() do
Telemetry.action(:retry_workflow, socket, [workflow_id: workflow_id], fn ->
Workflow.retry_jobs(socket.assigns.conf.name, workflow_id)
end)
socket
|> handle_refresh()
|> put_flash_with_clear(:info, "Workflow jobs retried")
else
put_flash_with_clear(socket, :error, "Retry requires Oban Pro")
end
{:noreply, socket}
end
def handle_info(_event, socket) do
{:noreply, socket}
end
end

View file

@ -0,0 +1,353 @@
defmodule Oban.Web.CronQuery do
@moduledoc false
import Ecto.Query
import Oban.Web.QueryHelpers
alias Oban.Cron.Expression
alias Oban.{Job, Met, Repo}
alias Oban.Web.{Cron, Search, Utils}
@compile {:no_warn_undefined, Oban.Pro.Cron}
@suggest_qualifier [
{"names:", "cron entry name", "names:my-cron"},
{"workers:", "cron worker name", "workers:MyApp.Worker"},
{"modes:", "cron mode (static/dynamic)", "modes:static"},
{"states:", "last execution state", "states:completed"}
]
@suggest_state [
{"available", "last job is available", "available"},
{"cancelled", "last job was cancelled", "cancelled"},
{"completed", "last job was completed", "completed"},
{"discarded", "last job was discarded", "discarded"},
{"executing", "last job is executing", "executing"},
{"retryable", "last job is retryable", "retryable"},
{"scheduled", "last job is scheduled", "scheduled"},
{"unknown", "no previous jobs available", "unknown"}
]
@suggest_mode [
{"static", "regular cron job", "static"},
{"dynamic", "dynamic cron job", "dynamic"}
]
@known_qualifiers MapSet.new(@suggest_qualifier, fn {qualifier, _, _} -> qualifier end)
# Searching
def filterable, do: ~w(names workers states modes)a
def parse(terms) when is_binary(terms) do
Search.parse(terms, &parse_term/1)
end
def suggest(terms, conf, _opts \\ []) do
terms
|> String.split(~r/\s+(?=([^\"]*\"[^\"]*\")*[^\"]*$)/)
|> List.last()
|> to_string()
|> case do
"" ->
@suggest_qualifier
last ->
case String.split(last, ":", parts: 2) do
["names", frag] -> suggest_names(frag, conf)
["workers", frag] -> suggest_workers(frag, conf)
["modes", frag] -> suggest_static(frag, @suggest_mode)
["states", frag] -> suggest_static(frag, @suggest_state)
[frag] -> suggest_static(frag, @suggest_qualifier)
_ -> []
end
end
end
defp suggest_static(fragment, possibilities) do
for {field, _, _} = suggest <- possibilities,
String.starts_with?(field, fragment),
do: suggest
end
defp suggest_names(fragment, conf) do
static_names =
conf.name
|> Met.crontab()
|> Enum.map(&entry_name/1)
dynamic_names =
if Utils.has_crons?(conf) do
query = from c in Oban.Pro.Cron, select: c.name
Repo.all(conf, query)
else
[]
end
Search.restrict_suggestions(static_names ++ dynamic_names, fragment)
end
defp suggest_workers(fragment, conf) do
conf.name
|> Met.crontab()
|> Enum.map(&elem(&1, 1))
|> Enum.map(&to_string/1)
|> Search.restrict_suggestions(fragment)
end
def append(terms, choice) do
Search.append(terms, choice, @known_qualifiers)
end
def complete(terms, conf) do
case suggest(terms, conf) do
[] ->
terms
[{match, _, _} | _] ->
append(terms, match)
end
end
defp parse_term("names:" <> names) do
{:names, String.split(names, ",")}
end
defp parse_term("workers:" <> workers) do
{:workers, String.split(workers, ",")}
end
defp parse_term("states:" <> states) do
parsed =
states
|> String.split(",")
|> Enum.map(fn
"unknown" -> nil
state -> state
end)
{:states, parsed}
end
defp parse_term("modes:" <> modes) do
{:modes, String.split(modes, ",")}
end
defp parse_term(_term), do: {:none, ""}
@history_limit 60
# Querying
def all_crons(params, conf) do
{sort_by, sort_dir} = parse_sort(params)
limit = Map.get(params, :limit, 20)
crontab = static_crontab(conf) ++ dynamic_crontab(conf)
history = crontab_history(crontab, conf)
conditions = Map.take(params, filterable())
crontab
|> Enum.map(&build_cron(&1, history))
|> Enum.filter(&filter(&1, conditions))
|> Enum.sort_by(&order(&1, sort_by), sort_dir)
|> Enum.take(limit)
end
def get_cron(name, conf) when is_binary(name) do
with entry when not is_nil(entry) <- find_cron_entry(name, conf) do
history = cron_history(name, conf)
build_cron(entry, %{name => history})
end
end
defp find_cron_entry(name, conf) do
static_entry =
conf
|> static_crontab()
|> Enum.find(fn {_, _, _, cron_name, _, _} -> cron_name == name end)
cond do
static_entry ->
static_entry
Utils.has_crons?(conf) ->
query =
from c in Oban.Pro.Cron,
where: c.name == ^name,
select: {c.expression, c.worker, c.opts, c.name, true, c.paused},
limit: 1
Repo.one(conf, query)
true ->
nil
end
end
def cron_history(name, conf) do
query =
Job
|> where(^filter_cron_name(name, conf))
|> order_by([j], desc: j.id)
|> limit(@history_limit)
|> select([j], %{
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)
})
conf
|> Repo.all(query)
|> Enum.reverse()
end
defp filter_cron_name(name, conf) when is_mysql(conf) or is_sqlite(conf) do
dynamic([j], fragment("json_extract(?, '$.cron_name') = ?", j.meta, ^name))
end
defp filter_cron_name(name, _conf) do
dynamic([j], fragment("? @> ?", j.meta, ^%{cron_name: name}))
end
defp static_crontab(conf) do
conf.name
|> Met.crontab()
|> Enum.map(fn {expr, worker, opts} = entry ->
{expr, worker, opts, entry_name(entry), false, false}
end)
end
defp entry_name({_expr, _worker, opts} = entry) do
Map.get_lazy(opts, "name", fn -> Oban.Plugins.Cron.entry_name(entry) end)
end
defp dynamic_crontab(conf) do
if Utils.has_crons?(conf) do
query = select(Oban.Pro.Cron, [c], {c.expression, c.worker, c.opts, c.name, true, c.paused})
Repo.all(conf, query)
end
end
# Construction
defp build_cron({expr, worker, opts, name, dynamic?, paused?}, history) do
jobs = Map.get(history, name, [])
last_job = List.last(jobs)
fields = [
name: name,
expression: expr,
worker: worker,
opts: opts,
dynamic?: dynamic?,
paused?: paused?,
next_at: next_at(expr),
last_at: last_at_from_job(last_job),
last_state: if(last_job, do: last_job.state),
history: jobs
]
struct!(Cron, fields)
end
defp last_at_from_job(nil), do: nil
defp last_at_from_job(%{finished_at: at}) when not is_nil(at), do: at
defp last_at_from_job(%{attempted_at: at}) when not is_nil(at), do: at
defp last_at_from_job(%{scheduled_at: at}) when not is_nil(at), do: at
defp last_at_from_job(_job), do: nil
def crontab_history(crontab, conf) when is_mysql(conf) or is_sqlite(conf) do
crontab
|> Enum.map(&elem(&1, 3))
|> Map.new(fn name -> {name, cron_history(name, conf)} end)
end
def crontab_history(crontab, conf) do
names = Enum.map(crontab, &elem(&1, 3))
inside =
from o in Job,
where:
fragment("? @> jsonb_build_object('cron_name', ?)", o.meta, parent_as(:list).value),
select: %{
cron_name: o.meta["cron_name"],
state: o.state,
attempted_at: o.attempted_at,
scheduled_at: o.scheduled_at,
finished_at:
fragment("COALESCE(?, ?, ?)", o.completed_at, o.cancelled_at, o.discarded_at)
},
select_merge: %{
rn: over(row_number(), partition_by: o.meta["cron_name"], order_by: [desc: o.id])
}
ranked = from t in subquery(inside), where: t.rn <= @history_limit
query =
from f in fragment("json_array_elements_text(?)", ^names),
as: :list,
left_lateral_join: j in subquery(ranked),
on: true,
order_by: [asc: j.rn],
select: {f.value, j}
conf
|> Repo.all(query)
|> Enum.group_by(&elem(&1, 0), fn {_name, job} -> job end)
|> Map.new(fn {name, jobs} ->
{name, jobs |> Enum.reject(&empty_job?/1) |> Enum.reverse()}
end)
end
defp empty_job?(nil), do: true
defp empty_job?(%{scheduled_at: nil}), do: true
defp empty_job?(_job), do: false
defp next_at(expression) do
expression
|> Expression.parse!()
|> Expression.next_at()
end
# Sorting
defp parse_sort(%{sort_by: "last_run", sort_dir: dir}) do
{:last_run, {String.to_existing_atom(dir), NaiveDateTime}}
end
defp parse_sort(%{sort_by: "next_run", sort_dir: dir}) do
{:next_run, {String.to_existing_atom(dir), NaiveDateTime}}
end
defp parse_sort(%{sort_by: sby, sort_dir: dir}) do
{String.to_existing_atom(sby), String.to_existing_atom(dir)}
end
defp parse_sort(_params), do: {:worker, :asc}
defp order(%{last_at: nil}, :last_run), do: ~U[2000-01-01 00:00:00Z]
defp order(%{last_at: last_at}, :last_run), do: last_at
defp order(%{name: name}, :name), do: name
defp order(%{next_at: next_at}, :next_run), do: next_at
defp order(%{expression: expression}, :schedule), do: expression
defp order(%{worker: worker}, :worker), do: worker
# Filtering
defp filter(_row, conditions) when conditions == %{}, do: true
defp filter(row, conditions) when is_map(conditions) do
Enum.all?(conditions, &filter(row, &1))
end
defp filter(cron, {:names, names}), do: cron.name in names
defp filter(cron, {:workers, workers}), do: cron.worker in workers
defp filter(cron, {:states, states}), do: cron.last_state in states
defp filter(cron, {:modes, modes}) do
if(cron.dynamic?, do: "dynamic", else: "static") in modes
end
end

View file

@ -2,6 +2,7 @@ defmodule Oban.Web.JobQuery do
@moduledoc false @moduledoc false
import Ecto.Query import Ecto.Query
import Oban.Web.QueryHelpers
alias Oban.{Config, Job, Repo} alias Oban.{Config, Job, Repo}
alias Oban.Web.{Cache, Resolver, Search} alias Oban.Web.{Cache, Resolver, Search}
@ -45,9 +46,7 @@ defmodule Oban.Web.JobQuery do
@states Map.new(Oban.Job.states(), &{to_string(&1), &1}) @states Map.new(Oban.Job.states(), &{to_string(&1), &1})
defguardp is_mysql(conf) when conf.engine == Oban.Engines.Dolphin @history_limit 60
defguardp is_sqlite(conf) when conf.engine == Oban.Engines.Lite
defmacrop json_table(field) do defmacrop json_table(field) do
quote do quote do
@ -105,40 +104,6 @@ defmodule Oban.Web.JobQuery do
end end
end end
defmacrop mysql_extract_type(field, path) do
quote do
fragment("lower(json_type(json_extract(?, ?)))", unquote(field), unquote(path))
end
end
defmacrop sqlite_extract_type(field, path) do
quote do
fragment("json_type(?->?)", unquote(field), unquote(path))
end
end
defmacrop postgres_extract_type(field, path) do
quote do
fragment("jsonb_typeof(?#>?)", unquote(field), unquote(path))
end
end
defmacrop sqlite_contains_any(column, list) do
quote do
fragment(
"""
exists (
select 1
from json_each(?) as t1, json_each(?) as t2
where t1.value = t2.value
)
""",
unquote(column),
^Oban.JSON.encode!(unquote(list))
)
end
end
# MySQL raises an out of bounds error when subtracting from an UNSIGNED value returns a value # MySQL raises an out of bounds error when subtracting from an UNSIGNED value returns a value
# less than 0. There's no standard `greatest/max` function that can clamp to 0, so we use a case # less than 0. There's no standard `greatest/max` function that can clamp to 0, so we use a case
# statement instead. # statement instead.
@ -494,6 +459,33 @@ defmodule Oban.Web.JobQuery do
def refresh_job(_conf, nil), do: nil def refresh_job(_conf, nil), do: nil
@history_states ~w(executing completed cancelled discarded)
def job_history(job, conf, _opts \\ []) do
worker = Map.get(job.meta, "worker", job.worker)
query =
Job
|> where([j], j.worker == ^worker)
|> where([j], j.id <= ^job.id)
|> where([j], j.state in @history_states)
|> order_by([j], desc: j.id)
|> limit(@history_limit)
|> select([j], %{
id: j.id,
state: j.state,
scheduled_at: j.scheduled_at,
attempted_at: j.attempted_at,
completed_at: j.completed_at,
cancelled_at: j.cancelled_at,
discarded_at: j.discarded_at
})
conf
|> Repo.all(query)
|> Enum.reverse()
end
def cancel_jobs(%Config{name: name}, [_ | _] = job_ids) do def cancel_jobs(%Config{name: name}, [_ | _] = job_ids) do
Oban.cancel_all_jobs(name, only_ids(job_ids)) Oban.cancel_all_jobs(name, only_ids(job_ids))
@ -688,7 +680,8 @@ defmodule Oban.Web.JobQuery do
order_by(query, [j], {^dir, j.queue}) order_by(query, [j], {^dir, j.queue})
end end
defp order(query, :time, state, dir) when state in ~w(available retryable scheduled) do defp order(query, :time, state, dir)
when state in ~w(suspended available retryable scheduled) do
order_by(query, [j], {^dir, j.scheduled_at}) order_by(query, [j], {^dir, j.scheduled_at})
end end

View file

@ -0,0 +1,47 @@
defmodule Oban.Web.QueryHelpers do
@moduledoc false
# Engine Guards
defguard is_mysql(conf) when conf.engine == Oban.Engines.Dolphin
defguard is_sqlite(conf) when conf.engine == Oban.Engines.Lite
# Type Extraction
defmacro mysql_extract_type(field, path) do
quote do
fragment("lower(json_type(json_extract(?, ?)))", unquote(field), unquote(path))
end
end
defmacro sqlite_extract_type(field, path) do
quote do
fragment("json_type(?->?)", unquote(field), unquote(path))
end
end
defmacro postgres_extract_type(field, path) do
quote do
fragment("jsonb_typeof(?#>?)", unquote(field), unquote(path))
end
end
# Array Containment
defmacro sqlite_contains_any(column, list) do
quote do
fragment(
"""
exists (
select 1
from json_each(?) as t1, json_each(?) as t2
where t1.value = t2.value
)
""",
unquote(column),
^Oban.JSON.encode!(unquote(list))
)
end
end
end

View file

@ -7,7 +7,7 @@ defmodule Oban.Web.QueueQuery do
@suggest_qualifier [ @suggest_qualifier [
{"nodes:", "host name", "nodes:machine@somehost"}, {"nodes:", "host name", "nodes:machine@somehost"},
{"modes:", "a concurrency mode such as global", "global"}, {"modes:", "a concurrency mode such as global", "global"},
{"stats:", "a status such as paused, global, etc.", "paused"} {"stats:", "a status such as paused or terminating", "paused"}
] ]
@suggest_mode [ @suggest_mode [
@ -92,22 +92,23 @@ defmodule Oban.Web.QueueQuery do
# Querying # Querying
def all_queues(params, %{name: name}) do def all_queues(params, conf, counts \\ %{})
{sort_by, sort_dir} = atomize_sort(params)
def all_queues(params, %{name: name}, counts) do
{sort_by, sort_dir} = parse_sort(params)
limit = Map.get(params, :limit) || Map.get(params, "limit")
conditions = Map.take(params, filterable()) conditions = Map.take(params, filterable())
counts = %{ queues =
available: Met.latest(name, :full_count, group: "queue", filters: [state: "available"]), name
executing: Met.latest(name, :full_count, group: "queue", filters: [state: "executing"]) |> Met.checks()
} |> Enum.group_by(& &1["queue"])
|> Enum.map(&new(&1, counts))
|> Enum.filter(&filter(&1, conditions))
|> Enum.sort_by(&order(&1, sort_by), sort_dir)
name if limit, do: Enum.take(queues, limit), else: queues
|> Met.checks()
|> Enum.group_by(& &1["queue"])
|> Enum.map(&new(&1, counts))
|> Enum.filter(&filter(&1, conditions))
|> Enum.sort_by(&order(&1, sort_by), sort_dir)
end end
defp new({name, checks}, counts) do defp new({name, checks}, counts) do
@ -116,11 +117,11 @@ defmodule Oban.Web.QueueQuery do
struct!(Queue, %{name: name, checks: checks, counts: counts}) struct!(Queue, %{name: name, checks: checks, counts: counts})
end end
defp atomize_sort(%{sort_by: sby, sort_dir: dir}) do defp parse_sort(%{sort_by: sby, sort_dir: dir}) do
{String.to_existing_atom(sby), String.to_existing_atom(dir)} {String.to_existing_atom(sby), String.to_existing_atom(dir)}
end end
defp atomize_sort(_params), do: {:name, :asc} defp parse_sort(_params), do: {:name, :asc}
# Filtering # Filtering

View file

@ -0,0 +1,473 @@
defmodule Oban.Web.WorkflowQuery do
@moduledoc false
import Ecto.Query
alias Oban.{Job, Repo}
alias Oban.Web.{Search, Workflow}
@default_sup_graph_limit 500
@default_sub_graph_limit 100
@default_suggest_limit 100
defmacrop has_workflow_id(meta, workflow_id) do
quote do
fragment(
"? \\? 'workflow_id' AND ?->>'workflow_id' = ?",
unquote(meta),
unquote(meta),
unquote(workflow_id)
)
end
end
defmacrop has_sup_workflow_id(meta, workflow_id) do
quote do
fragment(
"? \\? 'sup_workflow_id' AND ?->>'sup_workflow_id' = ?",
unquote(meta),
unquote(meta),
unquote(workflow_id)
)
end
end
defmacrop duration_seconds(completed_at, started_at) do
quote do
fragment(
"EXTRACT(EPOCH FROM (COALESCE(?, NOW()) - ?))",
unquote(completed_at),
unquote(started_at)
)
end
end
defmacrop total_jobs(wf) do
quote do
fragment(
"? + ? + ? + ? + ? + ? + ? + ?",
unquote(wf).suspended,
unquote(wf).available,
unquote(wf).scheduled,
unquote(wf).executing,
unquote(wf).retryable,
unquote(wf).completed,
unquote(wf).cancelled,
unquote(wf).discarded
)
end
end
defmacrop progress_percent(wf) do
quote do
fragment(
"?::float / NULLIF(? + ? + ? + ? + ? + ? + ? + ?, 0)",
unquote(wf).completed,
unquote(wf).suspended,
unquote(wf).available,
unquote(wf).scheduled,
unquote(wf).executing,
unquote(wf).retryable,
unquote(wf).completed,
unquote(wf).cancelled,
unquote(wf).discarded
)
end
end
defmacrop job_graph_select(job) do
quote do
%{
id: unquote(job).id,
state: unquote(job).state,
worker: unquote(job).worker,
meta:
fragment(
"""
jsonb_build_object(
'name', ?->>'name',
'deps', ?->'deps',
'workflow_name', ?->>'workflow_name',
'decorated_name', ?->>'decorated_name',
'handler', ?->>'handler',
'context', (?->>'context')::boolean
)
""",
unquote(job).meta,
unquote(job).meta,
unquote(job).meta,
unquote(job).meta,
unquote(job).meta,
unquote(job).meta
)
}
end
end
defmacrop aggregate_workflow_state(state) do
quote do
fragment(
"""
CASE
WHEN bool_or(? = 'executing') THEN 'executing'
WHEN bool_or(? = 'discarded') THEN 'discarded'
WHEN bool_or(? = 'cancelled') THEN 'cancelled'
WHEN bool_or(? = 'retryable') THEN 'retryable'
WHEN bool_and(? = 'completed') THEN 'completed'
ELSE 'pending'
END
""",
unquote(state),
unquote(state),
unquote(state),
unquote(state),
unquote(state)
)
end
end
defmacrop sub_workflow_parent_dep(sub_workflow_id, sup_workflow_id) do
quote do
fragment(
"""
(SELECT dep->>1
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)
""",
unquote(sub_workflow_id),
unquote(sup_workflow_id)
)
end
end
defmacrop sub_workflow_states(parent_id) do
quote do
fragment(
"""
(SELECT COALESCE(array_agg(state), '{}') FROM oban_workflows WHERE parent_id = ?)
""",
unquote(parent_id)
)
end
end
@suggest_qualifier [
{"ids:", "workflow id", "ids:01234567-89ab-cdef"},
{"names:", "workflow name", "names:order-fulfillment"},
{"queues:", "queue name", "queues:default"},
{"workers:", "worker module", "workers:MyApp.Worker"},
{"states:", "workflow state", "states:executing"}
]
@suggest_state [
{"executing", "workflow is running", "states:executing"},
{"completed", "workflow finished successfully", "states:completed"},
{"cancelled", "workflow was cancelled", "states:cancelled"},
{"discarded", "workflow has discarded jobs", "states:discarded"}
]
@known_qualifiers MapSet.new(@suggest_qualifier, fn {qualifier, _, _} -> qualifier end)
# Searching
def filterable, do: ~w(ids names queues workers states)a
def parse(terms) when is_binary(terms) do
Search.parse(terms, &parse_term/1)
end
def suggest(terms, conf, _opts \\ []) do
terms
|> String.split(~r/\s+(?=([^\"]*\"[^\"]*\")*[^\"]*$)/)
|> List.last()
|> to_string()
|> case do
"" ->
@suggest_qualifier
last ->
case String.split(last, ":", parts: 2) do
["ids", _frag] -> []
["names", frag] -> suggest_names(frag, conf)
["queues", frag] -> suggest_queues(frag, conf)
["workers", frag] -> suggest_workers(frag, conf)
["states", frag] -> suggest_static(frag, @suggest_state)
[frag] -> suggest_static(frag, @suggest_qualifier)
_ -> []
end
end
end
defp suggest_static(fragment, possibilities) do
for {field, _, _} = suggest <- possibilities,
String.starts_with?(field, fragment),
do: suggest
end
defp suggest_names(fragment, conf) do
query =
Workflow
|> select([wf], wf.name)
|> distinct(true)
|> limit(@default_suggest_limit)
conf
|> Repo.all(query)
|> Search.restrict_suggestions(fragment)
end
defp suggest_queues(fragment, conf) do
query =
Workflow
|> select([wf], fragment("jsonb_array_elements_text(?->'queues')", wf.meta))
|> distinct(true)
|> limit(@default_suggest_limit)
conf
|> Repo.all(query)
|> Search.restrict_suggestions(fragment)
end
defp suggest_workers(fragment, conf) do
query =
Workflow
|> select([wf], fragment("jsonb_array_elements_text(?->'workers')", wf.meta))
|> distinct(true)
|> limit(@default_suggest_limit)
conf
|> Repo.all(query)
|> Search.restrict_suggestions(fragment)
end
def append(terms, choice) do
Search.append(terms, choice, @known_qualifiers)
end
def complete(terms, conf) do
case suggest(terms, conf) do
[] -> terms
[{match, _, _} | _] -> append(terms, match)
end
end
defp parse_term("ids:" <> ids) do
{:ids, String.split(ids, ",")}
end
defp parse_term("names:" <> names) do
{:names, String.split(names, ",")}
end
defp parse_term("queues:" <> queues) do
{:queues, String.split(queues, ",")}
end
defp parse_term("workers:" <> workers) do
{:workers, String.split(workers, ",")}
end
defp parse_term("states:" <> states) do
{:states, String.split(states, ",")}
end
defp parse_term(_term), do: {:none, ""}
# Querying
def all_workflows(params, conf) 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)
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)})
|> then(&Repo.all(conf, &1))
|> Enum.map(&build_workflow/1)
end
defp apply_filters(query, params) do
query
|> filter_by_ids(Map.get(params, :ids))
|> filter_by_names(Map.get(params, :names))
|> filter_by_states(Map.get(params, :states))
|> filter_by_queues(Map.get(params, :queues))
|> filter_by_workers(Map.get(params, :workers))
end
defp filter_by_ids(query, nil), do: query
defp filter_by_ids(query, ids), do: where(query, [wf], wf.id in ^ids)
defp filter_by_names(query, nil), do: query
defp filter_by_names(query, names), do: where(query, [wf], wf.name in ^names)
defp filter_by_states(query, nil), do: query
defp filter_by_states(query, states), do: where(query, [wf], wf.state in ^states)
defp filter_by_queues(query, nil), do: query
defp filter_by_queues(query, queues) do
where(query, [wf], fragment("?->'queues' \\?| ?", wf.meta, ^queues))
end
defp filter_by_workers(query, nil), do: query
defp filter_by_workers(query, workers) do
where(query, [wf], fragment("?->'workers' \\?| ?", wf.meta, ^workers))
end
defp apply_sort(query, "inserted", dir) do
order_by(query, [wf], [{^dir, wf.inserted_at}])
end
defp apply_sort(query, "started", dir) do
order_by(query, [wf], [{^dir, wf.started_at}])
end
defp apply_sort(query, "duration", dir) do
order_by(query, [wf], [{^dir, duration_seconds(wf.completed_at, wf.started_at)}])
end
defp apply_sort(query, "total", dir) do
order_by(query, [wf], [{^dir, total_jobs(wf)}])
end
defp apply_sort(query, "progress", dir) do
order_by(query, [wf], [{^dir, progress_percent(wf)}])
end
defp apply_sort(query, _unknown, dir) do
apply_sort(query, "inserted", dir)
end
defp build_workflow({%Workflow{} = wf, sub_states}) do
pending = wf.available + wf.scheduled + wf.retryable
finished = wf.completed + wf.cancelled + wf.discarded
sub_activity = count_sub_activity(sub_states)
%{
id: wf.id,
name: wf.name,
state: String.to_existing_atom(wf.state),
total: wf.suspended + pending + wf.executing + finished + length(sub_states),
activity: %{
suspended: wf.suspended + sub_activity.suspended,
pending: pending + sub_activity.pending,
executing: wf.executing + sub_activity.executing,
finished: finished + sub_activity.finished
},
started_at: wf.started_at,
completed_at: wf.completed_at,
queues: Map.get(wf.meta, "queues", []),
display_name: wf.name || wf.id
}
end
defp count_sub_activity(states) do
Enum.reduce(states, %{suspended: 0, pending: 0, executing: 0, finished: 0}, fn state, acc ->
case state do
"suspended" ->
%{acc | suspended: acc.suspended + 1}
"executing" ->
%{acc | executing: acc.executing + 1}
state when state in ~w(completed cancelled discarded) ->
%{acc | finished: acc.finished + 1}
_ ->
%{acc | pending: acc.pending + 1}
end
end)
end
def get_workflow(conf, workflow_id) do
query = where(Workflow, [wf], wf.id == ^workflow_id)
Repo.one(conf, query)
end
def get_sup_workflow(conf, workflow_id) do
Workflow
|> join(:inner, [child], parent in Workflow, on: child.parent_id == parent.id)
|> where([child, _parent], child.id == ^workflow_id)
|> select([_child, parent], parent)
|> then(&Repo.one(conf, &1))
end
def get_sub_workflows(conf, workflow_id, limit \\ 10) do
Workflow
|> where([wf], wf.parent_id == ^workflow_id)
|> limit(^limit)
|> then(&Repo.all(conf, &1))
end
def get_sub_workflow_jobs(conf, sub_workflow_id, opts \\ []) do
limit = Keyword.get(opts, :limit, @default_sub_graph_limit)
query =
Job
|> where([j], has_workflow_id(j.meta, ^sub_workflow_id))
|> order_by([j], asc: j.id)
|> limit(^limit)
|> select([j], job_graph_select(j))
jobs = Repo.all(conf, query)
%{jobs: jobs, truncated: length(jobs) >= limit}
end
def get_workflow_graph(conf, workflow_id, opts \\ []) do
limit = Keyword.get(opts, :limit, @default_sup_graph_limit)
jobs = workflow_graph_jobs(conf, workflow_id, limit)
subs = workflow_graph_subs(conf, workflow_id)
%{jobs: jobs, sub_workflows: subs, truncated: length(jobs) >= limit}
end
defp workflow_graph_jobs(conf, workflow_id, limit) do
query =
Job
|> where([j], has_workflow_id(j.meta, ^workflow_id))
|> select([j], job_graph_select(j))
|> order_by([j], asc: j.id)
|> limit(^limit)
Repo.all(conf, query)
end
defp workflow_graph_subs(conf, workflow_id) do
base_query =
Job
|> where([j], has_sup_workflow_id(j.meta, ^workflow_id))
|> group_by([j], fragment("?->>'workflow_id'", j.meta))
|> select([j], %{
workflow_id: fragment("?->>'workflow_id'", j.meta),
workflow_name: fragment("MAX(?->>'workflow_name')", j.meta),
sub_name: fragment("MAX(?->>'sub_name')", j.meta),
state: aggregate_workflow_state(j.state)
})
query =
from(s in subquery(base_query),
select: %{
workflow_id: s.workflow_id,
workflow_name: s.workflow_name,
sub_name: s.sub_name,
state: s.state,
parent_dep: sub_workflow_parent_dep(s.workflow_id, ^workflow_id)
}
)
Repo.all(conf, query)
end
end

View file

@ -56,6 +56,12 @@ defmodule Oban.Web.Queue do
Enum.any?(checks, &is_map(&1["rate_limit"])) Enum.any?(checks, &is_map(&1["rate_limit"]))
end end
def partitioned?(%Queue{checks: checks}) do
Enum.any?(checks, fn check ->
get_in(check, ["global_limit", "partition"]) || get_in(check, ["rate_limit", "partition"])
end)
end
def terminating?(%Queue{checks: checks}) do def terminating?(%Queue{checks: checks}) do
Enum.any?(checks, & &1["shutdown_started_at"]) Enum.any?(checks, & &1["shutdown_started_at"])
end end

View file

@ -125,7 +125,19 @@ defmodule Oban.Web.Resolver do
@type access :: :all | :read_only | [{access_option(), boolean()}] | {:forbidden, Path.t()} @type access :: :all | :read_only | [{access_option(), boolean()}] | {:forbidden, Path.t()}
@type access_option :: @type access_option ::
:pause_queues | :scale_queues | :stop_queues | :cancel_jobs | :delete_jobs | :retry_jobs :cancel_jobs
| :cancel_workflows
| :delete_crons
| :delete_jobs
| :insert_crons
| :insert_jobs
| :pause_crons
| :pause_queues
| :retry_jobs
| :retry_workflows
| :scale_queues
| :stop_queues
| :update_crons
@type qualifier :: :args | :meta | :nodes | :queues | :tags | :workers @type qualifier :: :args | :meta | :nodes | :queues | :tags | :workers
@ -258,12 +270,19 @@ defmodule Oban.Web.Resolver do
The available fine grained access controls are: The available fine grained access controls are:
* `:cancel_jobs`
* `:cancel_workflows`
* `:delete_crons`
* `:delete_jobs`
* `:insert_crons`
* `:insert_jobs`
* `:pause_crons`
* `:pause_queues` * `:pause_queues`
* `:retry_jobs`
* `:retry_workflows`
* `:scale_queues` * `:scale_queues`
* `:stop_queues` * `:stop_queues`
* `:cancel_jobs` * `:update_crons`
* `:delete_jobs`
* `:retry_jobs`
Actions which aren't listed are considered disabled. Actions which aren't listed are considered disabled.

View file

@ -199,6 +199,8 @@ defmodule Oban.Web.Router do
live_session session_name, session_opts do live_session session_name, session_opts do
get "/css-:md5", Oban.Web.Assets, :css, as: :oban_web_asset get "/css-:md5", Oban.Web.Assets, :css, as: :oban_web_asset
get "/js-:md5", Oban.Web.Assets, :js, 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 "/", Oban.Web.DashboardLive, :home, route_opts
live "/:page", Oban.Web.DashboardLive, :index, route_opts live "/:page", Oban.Web.DashboardLive, :index, route_opts

View file

@ -0,0 +1,21 @@
defmodule Oban.Web.Timezones do
@moduledoc false
database = :oban_web |> :code.priv_dir() |> Path.join("timezones.txt")
@external_resource database
@timezones database
|> File.read!()
|> String.split("\n", trim: true)
def all, do: @timezones
def options do
Enum.map(all(), &{&1, &1})
end
def options_with_blank do
[{"", ""} | options()]
end
end

View file

@ -138,7 +138,7 @@ defmodule Oban.Web.Timing do
iex> Oban.Web.Timing.to_words(-1, relative: false) iex> Oban.Web.Timing.to_words(-1, relative: false)
"1s" "1s"
""" """
def to_words(ellapsed, opts \\ [relative: true]) when is_integer(ellapsed) do def to_words(ellapsed, opts \\ []) when is_integer(ellapsed) do
distance = distance =
case abs(ellapsed) do case abs(ellapsed) do
0 -> "now" 0 -> "now"
@ -151,7 +151,7 @@ defmodule Oban.Web.Timing do
end end
cond do cond do
not opts[:relative] -> distance opts[:relative] == false -> distance
ellapsed < 0 -> "#{distance} ago" ellapsed < 0 -> "#{distance} ago"
ellapsed > 0 -> "in #{distance}" ellapsed > 0 -> "in #{distance}"
true -> distance true -> distance
@ -252,14 +252,20 @@ defmodule Oban.Web.Timing do
defp truncate_sec(datetime), do: NaiveDateTime.truncate(datetime, :second) defp truncate_sec(datetime), do: NaiveDateTime.truncate(datetime, :second)
@doc """ @doc """
Convert a stringified timestamp (i.e. from an error) into a relative time. Convert a naive date time or iso8601 stringified version to a relative time.
""" """
def iso8601_to_words(iso8601, now \\ NaiveDateTime.utc_now()) do def datetime_to_words(datetime, opts \\ [])
def datetime_to_words(iso8601, opts) when is_binary(iso8601) do
{:ok, datetime} = NaiveDateTime.from_iso8601(iso8601) {:ok, datetime} = NaiveDateTime.from_iso8601(iso8601)
datetime_to_words(datetime, opts)
end
def datetime_to_words(datetime, opts) do
datetime datetime
|> NaiveDateTime.diff(now) |> NaiveDateTime.diff(NaiveDateTime.utc_now())
|> to_words() |> to_words(opts)
end end
defp pad(time, places \\ 2) do defp pad(time, places \\ 2) do

36
vendor/oban_web/lib/oban/web/utils.ex vendored Normal file
View file

@ -0,0 +1,36 @@
defmodule Oban.Web.Utils do
@moduledoc false
import Ecto.Query
alias Oban.Repo
def has_crons?(conf), do: has_table?(conf, "oban_crons")
def has_workflows?(conf), do: has_table?(conf, "oban_workflows")
def has_pro? do
persistent_cache(:pro?, fn -> Code.ensure_loaded?(Oban.Pro) end)
end
def persistent_cache(key, fun) when is_function(fun, 0) do
case :persistent_term.get(key, nil) do
nil -> tap(fun.(), &:persistent_term.put(key, &1))
val -> val
end
end
defp has_table?(conf, table_name) do
%{name: name, prefix: prefix} = conf
persistent_cache({:table?, name, table_name}, fn ->
query =
from("tables")
|> put_query_prefix("information_schema")
|> where(table_schema: ^prefix, table_name: ^table_name)
|> select(true)
Repo.one(conf, query) == true
end)
end
end

View file

@ -0,0 +1,35 @@
defmodule Oban.Web.Workflow do
@moduledoc false
use Ecto.Schema
@fields ~w(
id name parent_id inserted_at meta
suspended available scheduled executing retryable completed cancelled discarded
)a
@primary_key {:id, :string, autogenerate: false}
schema "oban_workflows" do
field :name, :string
field :parent_id, :string
field :state, :string
field :meta, :map
field :suspended, :integer, default: 0
field :available, :integer, default: 0
field :scheduled, :integer, default: 0
field :executing, :integer, default: 0
field :retryable, :integer, default: 0
field :completed, :integer, default: 0
field :cancelled, :integer, default: 0
field :discarded, :integer, default: 0
field :started_at, :utc_datetime_usec
field :completed_at, :utc_datetime_usec
field :inserted_at, :utc_datetime_usec
end
def changeset(params) do
Ecto.Changeset.cast(%__MODULE__{}, params, @fields)
end
end

View file

@ -2,7 +2,7 @@ defmodule Oban.Web.MixProject do
use Mix.Project use Mix.Project
@source_url "https://github.com/oban-bg/oban_web" @source_url "https://github.com/oban-bg/oban_web"
@version "2.11.8" @version "2.12.1"
def project do def project do
[ [
@ -17,7 +17,7 @@ defmodule Oban.Web.MixProject do
listeners: [Phoenix.CodeReloader], listeners: [Phoenix.CodeReloader],
package: package(), package: package(),
name: "Oban Web", name: "Oban Web",
description: "Dashboard for the Oban job processing framework" description: "Dashboard for the Oban job orchestration framework"
] ]
end end
@ -30,7 +30,14 @@ defmodule Oban.Web.MixProject do
end end
def cli do def cli do
[preferred_envs: ["test.ci": :test, "test.reset": :test, "test.setup": :test]] [
preferred_envs: [
precommit: :test,
"test.ci": :test,
"test.reset": :test,
"test.setup": :test
]
]
end end
defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(:test), do: ["lib", "test/support"]
@ -40,7 +47,9 @@ defmodule Oban.Web.MixProject do
[ [
maintainers: ["Parker Selbert"], maintainers: ["Parker Selbert"],
licenses: ["Apache-2.0"], licenses: ["Apache-2.0"],
files: ~w(lib priv/static* .formatter.exs mix.exs README* CHANGELOG* LICENSE*), files: ~w(
lib priv/static* priv/timezones.txt .formatter.exs mix.exs README* CHANGELOG* LICENSE*
),
links: %{ links: %{
Website: "https://oban.pro", Website: "https://oban.pro",
Changelog: "#{@source_url}/blob/main/CHANGELOG.md", Changelog: "#{@source_url}/blob/main/CHANGELOG.md",
@ -92,8 +101,8 @@ defmodule Oban.Web.MixProject do
{:phoenix_live_view, "~> 1.0"}, {:phoenix_live_view, "~> 1.0"},
# Oban # Oban
{:oban, "~> 2.19"}, {:oban, "~> 2.21"},
{:oban_met, "~> 1.0"}, {:oban_met, "~> 1.1"},
# Databases # Databases
{:ecto_sqlite3, "~> 0.18", only: [:dev, :test]}, {:ecto_sqlite3, "~> 0.18", only: [:dev, :test]},
@ -106,11 +115,12 @@ defmodule Oban.Web.MixProject do
{:faker, "~> 0.17", only: :dev}, {:faker, "~> 0.17", only: :dev},
{:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_reload, "~> 1.2", only: :dev},
{:tailwind, "~> 0.4", only: :dev, runtime: false}, {:tailwind, "~> 0.4", only: :dev, runtime: false},
{:tz, "~> 0.24", only: [:test, :dev]},
# Tooling # Tooling
{:credo, "~> 1.7", only: [:test, :dev], runtime: false}, {:credo, "~> 1.7", only: [:test, :dev], runtime: false},
{:floki, "~> 0.33", only: [:test, :dev]},
{:igniter, "~> 0.5", only: [:dev, :test]}, {:igniter, "~> 0.5", only: [:dev, :test]},
{:lazy_html, ">= 0.1.0", only: :test},
# Docs and Publishing # Docs and Publishing
{:ex_doc, "~> 0.34", only: :dev, runtime: false}, {:ex_doc, "~> 0.34", only: :dev, runtime: false},
@ -120,7 +130,7 @@ defmodule Oban.Web.MixProject do
defp oban_pro_dep do defp oban_pro_dep do
if oban_repo_configured?() do if oban_repo_configured?() do
[{:oban_pro, "~> 1.5", repo: :oban, only: [:test, :dev]}] [{:oban_pro, "~> 1.7.0-rc.0", repo: :oban, only: [:test, :dev]}]
else else
[] []
end end
@ -142,6 +152,7 @@ defmodule Oban.Web.MixProject do
[ [
"assets.build": ["tailwind default", "esbuild default"], "assets.build": ["tailwind default", "esbuild default"],
dev: "run --no-halt dev.exs", dev: "run --no-halt dev.exs",
precommit: ["test.ci"],
release: [ release: [
"assets.build", "assets.build",
"cmd git tag v#{@version} -f", "cmd git tag v#{@version} -f",
@ -149,6 +160,9 @@ defmodule Oban.Web.MixProject do
"cmd git push --tags", "cmd git push --tags",
"hex.publish --yes" "hex.publish --yes"
], ],
"dash.build": "cmd docker build -t oban-dash standalone",
"py.dev": "cmd --cd py uv run py-dev",
"py.install": "cmd --cd py uv sync",
"test.reset": ["ecto.drop --quiet", "test.setup"], "test.reset": ["ecto.drop --quiet", "test.setup"],
"test.setup": ["ecto.create --quiet", "ecto.migrate --quiet"], "test.setup": ["ecto.create --quiet", "ecto.migrate --quiet"],
"test.ci": [ "test.ci": [
@ -156,8 +170,7 @@ defmodule Oban.Web.MixProject do
"deps.unlock --check-unused", "deps.unlock --check-unused",
"credo --strict", "credo --strict",
"test --raise" "test --raise"
], ]
"dash.build": "cmd docker build -t oban-dash standalone"
] ]
end end
end end

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 380 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 373 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 211 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 496 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 339 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 241 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 211 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 302 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 284 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 253 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 253 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 250 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 479 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 340 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 719 B

View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 669 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 326 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 235 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 197 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 203 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 226 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 201 B

View file

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 202 B

View file

@ -0,0 +1,3 @@
<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.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>

After

Width:  |  Height:  |  Size: 567 B

View file

@ -0,0 +1,3 @@
<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 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>

After

Width:  |  Height:  |  Size: 221 B

View file

@ -0,0 +1,4 @@
<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.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 0 1 1.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.559.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.894.149c-.424.07-.764.383-.929.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 0 1-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.398.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 0 1-.12-1.45l.527-.737c.25-.35.272-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.108-1.204l-.526-.738a1.125 1.125 0 0 1 .12-1.45l.773-.773a1.125 1.125 0 0 1 1.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894Z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,3 @@
<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 12a7.5 7.5 0 0015 0m-15 0a7.5 7.5 0 1115 0m-15 0H3m16.5 0H21m-1.5 0H12m-8.457 3.077l1.41-.513m14.095-5.13l1.41-.513M5.106 17.785l1.15-.964m11.49-9.642l1.149-.964M7.501 19.795l.75-1.3m7.5-12.99l.75-1.3m-6.063 16.658l.26-1.477m2.605-14.772l.26-1.477m0 17.726l-.26-1.477M10.698 4.614l-.26-1.477M16.5 19.794l-.75-1.299M7.5 4.205L12 12m6.894 5.785l-1.149-.964M6.256 7.178l-1.15-.964m15.352 8.864l-1.41-.513M4.954 9.435l-1.41-.514M12.002 12l-3.75 6.495"/>
</svg>

After

Width:  |  Height:  |  Size: 627 B

View file

@ -0,0 +1,3 @@
<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 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

View file

@ -0,0 +1,3 @@
<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 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
</svg>

After

Width:  |  Height:  |  Size: 431 B

View file

@ -0,0 +1,3 @@
<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.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 393 B

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