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
10
mix.lock
|
|
@ -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_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"},
|
||||
"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"},
|
||||
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
|
||||
"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_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_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"},
|
||||
"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_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.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_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"},
|
||||
"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"},
|
||||
|
|
|
|||
4
vendor/README.md
vendored
|
|
@ -37,7 +37,7 @@ Advanced Oban features including batching, workflows, dynamic partitioning, and
|
|||
|
||||
### Oban Web
|
||||
|
||||
**Version**: 2.11.8
|
||||
**Version**: 2.12.1
|
||||
**Source**: https://hex.pm/packages/oban_web
|
||||
**License**: Apache-2.0
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ end
|
|||
|
||||
### Oban Met
|
||||
|
||||
**Version**: 1.0.6
|
||||
**Version**: 1.1.0
|
||||
**Source**: https://hex.pm/packages/oban_met
|
||||
**License**: Apache-2.0
|
||||
|
||||
|
|
|
|||
BIN
vendor/oban_met/.hex
vendored
13
vendor/oban_met/CHANGELOG.md
vendored
|
|
@ -1,10 +1,19 @@
|
|||
# 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
|
||||
|
||||
### 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`
|
||||
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
|
||||
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
|
||||
map structure rather than a list of lists.
|
||||
|
|
|
|||
10
vendor/oban_met/README.md
vendored
|
|
@ -106,6 +106,16 @@ declares an override to set the limit to 200k:
|
|||
{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
|
||||
|
||||
Met will create the necessary estimate function automatically when possible. The migration isn't
|
||||
|
|
|
|||
4
vendor/oban_met/hex_metadata.config
vendored
|
|
@ -4,7 +4,7 @@
|
|||
<<"https://github.com/oban-bg/oban_met/blob/main/CHANGELOG.md">>},
|
||||
{<<"GitHub">>,<<"https://github.com/oban-bg/oban_met">>}]}.
|
||||
{<<"name">>,<<"oban_met">>}.
|
||||
{<<"version">>,<<"1.0.6">>}.
|
||||
{<<"version">>,<<"1.1.0">>}.
|
||||
{<<"description">>,
|
||||
<<"A distributed, compacting, multidimensional, telemetry-powered time series datastore">>}.
|
||||
{<<"elixir">>,<<"~> 1.15">>}.
|
||||
|
|
@ -23,6 +23,6 @@
|
|||
[[{<<"name">>,<<"oban">>},
|
||||
{<<"app">>,<<"oban">>},
|
||||
{<<"optional">>,false},
|
||||
{<<"requirement">>,<<"~> 2.19">>},
|
||||
{<<"requirement">>,<<"~> 2.21">>},
|
||||
{<<"repository">>,<<"hexpm">>}]]}.
|
||||
{<<"build_tools">>,[<<"mix">>]}.
|
||||
|
|
|
|||
1
vendor/oban_met/lib/oban/met/listener.ex
vendored
|
|
@ -148,7 +148,6 @@ defmodule Oban.Met.Listener do
|
|||
|
||||
# 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`.
|
||||
defp trans_state(:cancelled), do: :cancelled
|
||||
defp trans_state(:discard), do: :discarded
|
||||
defp trans_state(:exhausted), do: :discarded
|
||||
defp trans_state(:failure), do: :retryable
|
||||
|
|
|
|||
3
vendor/oban_met/lib/oban/met/reporter.ex
vendored
|
|
@ -26,7 +26,8 @@ defmodule Oban.Met.Reporter do
|
|||
"discarded" => [],
|
||||
"executing" => [],
|
||||
"retryable" => [],
|
||||
"scheduled" => []
|
||||
"scheduled" => [],
|
||||
"suspended" => []
|
||||
}
|
||||
|
||||
defstruct [
|
||||
|
|
|
|||
4
vendor/oban_met/mix.exs
vendored
|
|
@ -2,7 +2,7 @@ defmodule Oban.Met.MixProject do
|
|||
use Mix.Project
|
||||
|
||||
@source_url "https://github.com/oban-bg/oban_met"
|
||||
@version "1.0.6"
|
||||
@version "1.1.0"
|
||||
|
||||
def project do
|
||||
[
|
||||
|
|
@ -97,7 +97,7 @@ defmodule Oban.Met.MixProject do
|
|||
|
||||
defp deps do
|
||||
[
|
||||
{:oban, "~> 2.19"},
|
||||
{:oban, "~> 2.21"},
|
||||
{:ecto_sqlite3, "~> 0.18", only: [:test, :dev]},
|
||||
{:postgrex, "~> 0.20", only: [:test, :dev]},
|
||||
{:stream_data, "~> 1.1", only: [:test, :dev]},
|
||||
|
|
|
|||
BIN
vendor/oban_web/.hex
vendored
482
vendor/oban_web/CHANGELOG.md
vendored
|
|
@ -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
|
||||
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!
|
||||
## 🔀 Workflows Page
|
||||
|
||||
Web v2.11 is licensed under Apache 2.0, just like Oban and Elixir itself. Previous versions are
|
||||
commercially licensed, therefore private, and won't be published to Hex.
|
||||
There is a new page for viewing, filtering, and generally managing workflows. The table displays
|
||||
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
|
||||
open source Oban, and now Web, possible 💛.
|
||||
<video autoplay loop muted playsinline loading="lazy" preload="none" style="width: 100%; border-radius: 12px;">
|
||||
<source src="https://media.oban.pro/web-2-12-workflows-av1.mp4" type="video/mp4">
|
||||
</video>
|
||||
|
||||
[fos]: https://github.com/oban-bg/oban_web
|
||||
[hex]: https://hex.pm/packages/oban_web
|
||||
[ins]: installation.md
|
||||
Clicking into a workflow brings you to a detail view with an interactive graph showing jobs as
|
||||
stateful nodes with dependencies. The graph supports panning, zooming, directional layout
|
||||
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
|
||||
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.
|
||||
## ⏰ Crons Page
|
||||
|
||||
## 🎛️ 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
|
||||
header allows switching between running Oban instances at runtime. There's no need to mount
|
||||
additional dashboards in your application's router to handle multiple instances. The most recently
|
||||
selected instance persists between mounts.
|
||||
<video autoplay loop muted playsinline loading="lazy" preload="none" style="width: 100%; border-radius: 12px;">
|
||||
<source src="https://media.oban.pro/web-2-12-crons-av1.mp4" type="video/mp4">
|
||||
</video>
|
||||
|
||||
```diff
|
||||
- oban_dashboard "/oban_a", oban_name: Oban.A
|
||||
- oban_dashboard "/oban_b", oban_name: Oban.B
|
||||
- oban_dashboard "/oban_c", oban_name: Oban.C
|
||||
+ oban_dashboard "/oban"
|
||||
```
|
||||
The cron detail view includes natural language expressions like "Daily at 8:00 and 9:00" or
|
||||
"Weekdays except Monday", along with cron entry specifics, and recent job history.
|
||||
|
||||
This also eliminates the need for additional router configuration, as the dashboard will select
|
||||
the first running Oban instance it finds (with a preference for the default `Oban`).
|
||||
For Pro users, `DynamicCron` entires can be created, edited, paused, resumed, or deleted directly
|
||||
from a form on the details page.
|
||||
|
||||
```diff
|
||||
- oban_dashboard "/oban", oban_name: MyOban
|
||||
+ oban_dashboard "/oban"
|
||||
```
|
||||
## 🔍 Job Details
|
||||
|
||||
## ☯️ 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
|
||||
functionality. This makes interacting with jobs clearer while queues gain some much requested
|
||||
functionality:
|
||||
<video autoplay loop muted playsinline loading="lazy" preload="none" style="width: 100%; border-radius: 12px;">
|
||||
<source src="https://media.oban.pro/web-2-12-jobs-details-av1.mp4" type="video/mp4">
|
||||
</video>
|
||||
|
||||
- Sidebar - a new queue sidebar shows status counts and enables filtering by statuses such as
|
||||
`paused` or `terminating`.
|
||||
A scoped chart displays execution history for that worker's previous jobs and jobs in an
|
||||
incomplete, non-executing state can now be edited directly.
|
||||
|
||||
- Filtering - queues are auto-complete filterable just like jobs, making it possible to find
|
||||
queues running on a particular node or narrow down by status.
|
||||
Executing Pro jobs display live diagnostics including process status, reductions, memory, and
|
||||
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
|
||||
are in the sub-queue page.
|
||||
<video autoplay loop muted playsinline loading="lazy" preload="none" style="width: 100%; border-radius: 12px;">
|
||||
<source src="https://media.oban.pro/web-2-12-queues-av1.mp4" type="video/mp4">
|
||||
</video>
|
||||
|
||||
## 🕯️ Operate on Full Selection
|
||||
|
||||
Apply bulk actions to all selected jobs, not just those visible on the current page.
|
||||
|
||||
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.
|
||||
The queue detail page adds status badges for paused, partial, and terminating states, with
|
||||
pause/resume, stop, and edit buttons in the header. Partitioning controls are expanded with meta
|
||||
options and burst mode configuration.
|
||||
|
||||
## v2.12.1 - 2026-03-25
|
||||
|
||||
### 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.
|
||||
|
||||
- [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
|
||||
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.
|
||||
|
||||
### 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
|
||||
immutable asset serving.
|
||||
The refresh reverted to the original value between changes because there wasn't a new liveview
|
||||
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
|
||||
or larger screens. The adjusted size is persisted as a setting within local storage, preserving
|
||||
it across reloads.
|
||||
- [Dashboard] Serve dynamically loaded mask based svg icons
|
||||
|
||||
- [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
|
||||
essential to retain sidebar changes during page transitions.
|
||||
- [Dashboard] Serve font as static asset instead of embedding
|
||||
|
||||
- [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
|
||||
selected queues.
|
||||
- [Dashboard] Add help button to primary toolbar
|
||||
|
||||
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
|
||||
|
||||
- [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
|
||||
ensures functions are correctly identified, as they aren't automatically loaded locally by
|
||||
Elixir (unless eager module loading is turned on).
|
||||
Ensure the instance name and allowed instances are strings before comparison
|
||||
|
||||
- [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
|
||||
when it completed.
|
||||
Replace telemetry-based init that could block for 15 seconds waiting for an init event that may
|
||||
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
|
||||
any non-id params on navigation. The new approach uses a history hook that leverages native
|
||||
window history instead.
|
||||
When the metric reporter's `check_interval` exceeds the 2s lookback window, counts briefly show
|
||||
as zero between broadcasts. Cache previous non-empty counts in a centralized Metrics module and
|
||||
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.
|
||||
This should make it easier to identify metric issues on solo nodes, e.g. in dev or a
|
||||
staging environment.
|
||||
The PG notifier can't (easily) connect to an external cluster for notifications. Connection is
|
||||
possible through the Postgres notifier.
|
||||
|
||||
### 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"`.
|
||||
|
|
|
|||
24
vendor/oban_web/README.md
vendored
|
|
@ -108,6 +108,30 @@ For development, a single file server that generates a wide variety of fake jobs
|
|||
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
|
||||
|
||||
Oban Pro is an optional dependency for development and testing. The test suite will automatically
|
||||
|
|
|
|||
117
vendor/oban_web/hex_metadata.config
vendored
|
|
@ -4,16 +4,20 @@
|
|||
<<"https://github.com/oban-bg/oban_web/blob/main/CHANGELOG.md">>},
|
||||
{<<"GitHub">>,<<"https://github.com/oban-bg/oban_web">>}]}.
|
||||
{<<"name">>,<<"oban_web">>}.
|
||||
{<<"version">>,<<"2.11.8">>}.
|
||||
{<<"description">>,<<"Dashboard for the Oban job processing framework">>}.
|
||||
{<<"version">>,<<"2.12.1">>}.
|
||||
{<<"description">>,<<"Dashboard for the Oban job orchestration framework">>}.
|
||||
{<<"elixir">>,<<"~> 1.15">>}.
|
||||
{<<"files">>,
|
||||
[<<"lib">>,<<"lib/mix">>,<<"lib/mix/tasks">>,
|
||||
<<"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/telemetry.ex">>,<<"lib/oban/web/cache.ex">>,
|
||||
<<"lib/oban/web/plugins">>,<<"lib/oban/web/plugins/stats.ex">>,
|
||||
<<"lib/oban/web/queue.ex">>,<<"lib/oban/web/queries">>,
|
||||
<<"lib/oban/web/cron.ex">>,<<"lib/oban/web/dashboard_live.ex">>,
|
||||
<<"lib/oban/web/page.ex">>,<<"lib/oban/web/telemetry.ex">>,
|
||||
<<"lib/oban/web/cache.ex">>,<<"lib/oban/web/plugins">>,
|
||||
<<"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/queue_query.ex">>,<<"lib/oban/web/router.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/live.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/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/theme_component.ex">>,<<"lib/oban/web/live/queues">>,
|
||||
<<"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_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/sidebar_component.ex">>,
|
||||
<<"lib/oban/web/live/jobs/new_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/chart_component.ex">>,
|
||||
<<"lib/oban/web/live/connectivity_component.ex">>,
|
||||
<<"lib/oban/web/live/instances_component.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/pages">>,<<"lib/oban/web/pages/jobs_page.ex">>,
|
||||
<<"lib/oban/web/pages/queues_page.ex">>,<<"lib/oban/web/resolver.ex">>,
|
||||
<<"lib/oban/web/colors.ex">>,<<"lib/oban/web/cron_expr.ex">>,
|
||||
<<"lib/oban/web/application.ex">>,<<"lib/oban/web/utils.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/helpers/queue_helper.ex">>,<<"lib/oban/web/assets.ex">>,
|
||||
<<"lib/oban/web/search.ex">>,<<"lib/oban/web/exceptions.ex">>,
|
||||
<<"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">>]}.
|
||||
{<<"app">>,<<"oban_web">>}.
|
||||
{<<"licenses">>,[<<"Apache-2.0">>]}.
|
||||
|
|
@ -80,11 +169,11 @@
|
|||
[{<<"name">>,<<"oban">>},
|
||||
{<<"app">>,<<"oban">>},
|
||||
{<<"optional">>,false},
|
||||
{<<"requirement">>,<<"~> 2.19">>},
|
||||
{<<"requirement">>,<<"~> 2.21">>},
|
||||
{<<"repository">>,<<"hexpm">>}],
|
||||
[{<<"name">>,<<"oban_met">>},
|
||||
{<<"app">>,<<"oban_met">>},
|
||||
{<<"optional">>,false},
|
||||
{<<"requirement">>,<<"~> 1.0">>},
|
||||
{<<"requirement">>,<<"~> 1.1">>},
|
||||
{<<"repository">>,<<"hexpm">>}]]}.
|
||||
{<<"build_tools">>,[<<"mix">>]}.
|
||||
|
|
|
|||
67
vendor/oban_web/lib/oban/web/assets.ex
vendored
|
|
@ -5,6 +5,36 @@ defmodule Oban.Web.Assets do
|
|||
|
||||
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 =
|
||||
for app <- ~w(phoenix phoenix_html phoenix_live_view)a do
|
||||
path = Application.app_dir(app, ["priv", "static", "#{app}.js"])
|
||||
|
|
@ -12,13 +42,8 @@ defmodule Oban.Web.Assets do
|
|||
path
|
||||
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")
|
||||
|
||||
@css File.read!(css_path)
|
||||
|
||||
@js """
|
||||
#{for path <- phoenix_js_paths, do: path |> File.read!() |> String.replace("//# sourceMappingURL=", "// ")}
|
||||
#{File.read!(js_path)}
|
||||
|
|
@ -28,9 +53,34 @@ defmodule Oban.Web.Assets do
|
|||
def init(asset), do: asset
|
||||
|
||||
@impl Plug
|
||||
def call(conn, asset) do
|
||||
{contents, content_type} = contents_and_type(asset)
|
||||
def call(conn, :css) do
|
||||
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
|
||||
|> put_resp_header("content-type", content_type)
|
||||
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|
||||
|
|
@ -39,9 +89,6 @@ defmodule Oban.Web.Assets do
|
|||
|> halt()
|
||||
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
|
||||
md5 = Base.encode16(:crypto.hash(:md5, val), case: :lower)
|
||||
|
||||
|
|
|
|||
93
vendor/oban_web/lib/oban/web/colors.ex
vendored
Normal 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
|
||||
310
vendor/oban_web/lib/oban/web/components/core.ex
vendored
|
|
@ -41,65 +41,6 @@ defmodule Oban.Web.Components.Core do
|
|||
"""
|
||||
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
|
||||
attr :name, :string, required: true
|
||||
attr :options, :list, required: true
|
||||
|
|
@ -114,7 +55,7 @@ defmodule Oban.Web.Components.Core do
|
|||
<button
|
||||
aria-expanded="true"
|
||||
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}
|
||||
disabled={@disabled}
|
||||
id={"#{@name}-menu-toggle"}
|
||||
|
|
@ -200,8 +141,8 @@ defmodule Oban.Web.Components.Core do
|
|||
phx-value-id={@value}
|
||||
rel="check"
|
||||
>
|
||||
<div class={["w-4 h-4 rounded border", @style]}>
|
||||
<Icons.check class="w-3.5 h-3.5 text-white dark:text-gray-900" />
|
||||
<div class={["w-4 h-4 flex items-center justify-center rounded border", @style]}>
|
||||
<Icons.check class="w-3 h-3 text-white dark:text-gray-900" />
|
||||
</div>
|
||||
</button>
|
||||
"""
|
||||
|
|
@ -231,14 +172,253 @@ defmodule Oban.Web.Components.Core do
|
|||
phx-target={@myself}
|
||||
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 %>
|
||||
<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 %>
|
||||
<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 %>
|
||||
</div>
|
||||
</button>
|
||||
"""
|
||||
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
|
||||
|
|
|
|||
192
vendor/oban_web/lib/oban/web/components/form_components.ex
vendored
Normal 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
|
||||
867
vendor/oban_web/lib/oban/web/components/icons.ex
vendored
|
|
@ -1,670 +1,64 @@
|
|||
defmodule Oban.Web.Components.Icons do
|
||||
use Oban.Web, :html
|
||||
@moduledoc false
|
||||
|
||||
# Helpers
|
||||
use Phoenix.Component
|
||||
|
||||
attr :rest, :global,
|
||||
default: %{
|
||||
"aria-hidden": "true",
|
||||
class: "w-4 h-4",
|
||||
fill: "currentColor",
|
||||
viewBox: "0 0 16 16"
|
||||
}
|
||||
@icons_path Application.app_dir(:oban_web, ["priv", "static", "icons"])
|
||||
|
||||
slot :inner_block, required: true
|
||||
@outline_path Path.join(@icons_path, "outline")
|
||||
@solid_path Path.join(@icons_path, "solid")
|
||||
|
||||
defp svg_mini(assigns) do
|
||||
~H"""
|
||||
<svg {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</svg>
|
||||
"""
|
||||
@outline_icons @outline_path
|
||||
|> File.ls!()
|
||||
|> Enum.filter(&String.ends_with?(&1, ".svg"))
|
||||
|> Enum.map(&String.trim_trailing(&1, ".svg"))
|
||||
|
||||
@solid_icons @solid_path
|
||||
|> File.ls!()
|
||||
|> Enum.filter(&String.ends_with?(&1, ".svg"))
|
||||
|> Enum.map(&String.trim_trailing(&1, ".svg"))
|
||||
|
||||
for icon <- @outline_icons do
|
||||
@external_resource Path.join(@outline_path, "#{icon}.svg")
|
||||
|
||||
func_name = icon |> String.replace("-", "_") |> String.to_atom()
|
||||
|
||||
@doc false
|
||||
attr :class, :any, default: "w-6 h-6"
|
||||
attr :rest, :global
|
||||
|
||||
def unquote(func_name)(assigns) do
|
||||
assigns = assign(assigns, :icon_path, "outline/#{unquote(icon)}.svg")
|
||||
|
||||
render_icon(assigns)
|
||||
end
|
||||
end
|
||||
|
||||
attr :rest, :global,
|
||||
default: %{
|
||||
"stroke-width": "1.5",
|
||||
class: "w-6 h-6",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
viewBox: "0 0 24 24"
|
||||
}
|
||||
for icon <- @solid_icons do
|
||||
@external_resource Path.join(@solid_path, "#{icon}.svg")
|
||||
|
||||
slot :inner_block, required: true
|
||||
func_name =
|
||||
icon
|
||||
|> String.replace("-", "_")
|
||||
|> Kernel.<>("_solid")
|
||||
|> String.to_atom()
|
||||
|
||||
defp svg_outline(assigns) do
|
||||
~H"""
|
||||
<svg {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</svg>
|
||||
"""
|
||||
@doc false
|
||||
attr :class, :any, default: "w-6 h-6"
|
||||
attr :rest, :global
|
||||
|
||||
def unquote(func_name)(assigns) do
|
||||
assigns = assign(assigns, :icon_path, "solid/#{unquote(icon)}.svg")
|
||||
|
||||
render_icon(assigns)
|
||||
end
|
||||
end
|
||||
|
||||
attr :rest, :global,
|
||||
default: %{
|
||||
"aria-hidden": "true",
|
||||
class: "w-6 h-6",
|
||||
fill: "currentColor",
|
||||
viewBox: "0 0 24 24"
|
||||
}
|
||||
# Inline Icons
|
||||
|
||||
slot :inner_block, required: true
|
||||
attr :rest, :global, default: %{class: "w-6 h-6"}
|
||||
|
||||
defp svg_solid(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
|
||||
def oban_pro_logo(assigns) do
|
||||
~H"""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {@rest}>
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
|
|
@ -722,156 +116,47 @@ defmodule Oban.Web.Components.Icons do
|
|||
"""
|
||||
end
|
||||
|
||||
attr :rest, :global
|
||||
|
||||
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
|
||||
attr :rest, :global, default: %{class: "w-6 h-6"}
|
||||
|
||||
def spinner(assigns) do
|
||||
~H"""
|
||||
<svg viewBox="0 0 100 101" fill="none" {@rest}>
|
||||
<path
|
||||
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"
|
||||
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 viewBox="0 0 24 24" fill="none" {@rest}>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.5" opacity="0.25" />
|
||||
<path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
"""
|
||||
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"""
|
||||
<.svg_outline {@rest}>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
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"
|
||||
/>
|
||||
</.svg_outline>
|
||||
<span
|
||||
class={["block bg-current", @normalized_class]}
|
||||
style={"mask-image: url(#{@icon_url}); -webkit-mask-image: url(#{@icon_url}); mask-size: contain; mask-repeat: no-repeat; mask-position: center;"}
|
||||
aria-hidden="true"
|
||||
{@rest}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
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"""
|
||||
<.svg_outline {@rest}>
|
||||
<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>
|
||||
"""
|
||||
_ ->
|
||||
"/icons/#{path}"
|
||||
end
|
||||
end
|
||||
|
||||
attr :rest, :global
|
||||
|
||||
def sun(assigns) do
|
||||
~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
|
||||
defp normalize_class(class) when is_binary(class), do: class
|
||||
defp normalize_class(class) when is_list(class), do: class
|
||||
defp normalize_class(nil), do: "w-6 h-6"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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="rounded-lg ring-1 ring-black/5 overflow-hidden">
|
||||
<div class="p-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 text-green-400">
|
||||
<Icons.check_circle />
|
||||
</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">
|
||||
{Phoenix.Flash.get(assigns.flash, :info)}
|
||||
</p>
|
||||
|
|
@ -131,7 +131,7 @@ defmodule Oban.Web.Layouts do
|
|||
<div class="ml-4 flex-shrink-0 flex">
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
|
|
@ -148,7 +148,7 @@ defmodule Oban.Web.Layouts do
|
|||
~H"""
|
||||
<nav class="flex space-x-2">
|
||||
<.link
|
||||
:for={page <- [:jobs, :queues]}
|
||||
:for={page <- [:jobs, :queues, :crons, :workflows]}
|
||||
class={link_class(@page, page)}
|
||||
data-shortcut={JS.patch(oban_path(page))}
|
||||
id={"nav-#{page}"}
|
||||
|
|
@ -178,7 +178,7 @@ defmodule Oban.Web.Layouts do
|
|||
<.version name="Oban.Pro" version={@pro_version} />
|
||||
|
||||
<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">
|
||||
Made by Oban Pro
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
<.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.RefreshComponent} id="refresh" refresh={@refresh} />
|
||||
<.live_component module={Oban.Web.HelpComponent} id="help" />
|
||||
<.live_component
|
||||
module={Oban.Web.InstancesComponent}
|
||||
id="instances"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ defmodule Oban.Web.SidebarComponents do
|
|||
<header class="flex justify-between items-center py-3">
|
||||
<button
|
||||
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}"}
|
||||
phx-click={toggle(@name)}
|
||||
phx-hook="Tippy"
|
||||
|
|
@ -47,7 +47,7 @@ defmodule Oban.Web.SidebarComponents do
|
|||
<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">
|
||||
<span :for={header <- @headers} class="w-10">{header}</span>
|
||||
<span :for={header <- @headers} class="w-12">{header}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
16
vendor/oban_web/lib/oban/web/cron.ex
vendored
Normal 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
|
||||
348
vendor/oban_web/lib/oban/web/cron_expr.ex
vendored
Normal 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
|
||||
48
vendor/oban_web/lib/oban/web/dashboard_live.ex
vendored
|
|
@ -1,7 +1,7 @@
|
|||
defmodule Oban.Web.DashboardLive do
|
||||
use Oban.Web, :live_view
|
||||
|
||||
alias Oban.Web.{JobsPage, QueuesPage}
|
||||
alias Oban.Web.{CronsPage, JobsPage, QueuesPage, WorkflowsPage}
|
||||
|
||||
@impl Phoenix.LiveView
|
||||
def mount(params, session, socket) do
|
||||
|
|
@ -15,8 +15,8 @@ defmodule Oban.Web.DashboardLive do
|
|||
sidebar_width = restore_state(socket, "sidebar_width", 320)
|
||||
oban = current_oban_instance(session, socket)
|
||||
|
||||
conf = await_init([oban], :supervisor)
|
||||
_met = await_init([oban, Oban.Met], :met)
|
||||
conf = await_init([oban])
|
||||
_met = await_init([oban, Oban.Met])
|
||||
page = resolve_page(params)
|
||||
|
||||
Process.put(:routing, {socket, prefix})
|
||||
|
|
@ -196,46 +196,32 @@ defmodule Oban.Web.DashboardLive do
|
|||
|
||||
## 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
|
||||
nil when retries > 0 ->
|
||||
Process.sleep(timeout)
|
||||
|
||||
await_init(args, Keyword.put(opts, :retries, retries - 1))
|
||||
|
||||
nil ->
|
||||
ref = make_ref()
|
||||
|
||||
: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
|
||||
raise RuntimeError, "no config registered for #{inspect(args)} instance"
|
||||
|
||||
pid when is_pid(pid) ->
|
||||
Oban.config(oban_name)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def relay_init(_event, _timing, %{conf: conf}, {args, pid}) do
|
||||
send(pid, {args, conf})
|
||||
end
|
||||
|
||||
## Render Helpers
|
||||
|
||||
defp resolve_page(%{"page" => "jobs"}), do: %{name: :jobs, comp: JobsPage}
|
||||
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}
|
||||
|
||||
## Refresh Helpers
|
||||
|
|
|
|||
5
vendor/oban_web/lib/oban/web/helpers.ex
vendored
|
|
@ -109,7 +109,8 @@ defmodule Oban.Web.Helpers do
|
|||
|
||||
{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, ",")}
|
||||
|
||||
{key, val} ->
|
||||
|
|
@ -117,8 +118,6 @@ defmodule Oban.Web.Helpers do
|
|||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
"""
|
||||
def active_filter?(params, :state, value) do
|
||||
params[:state] == value or (is_nil(params[:state]) and value == "executing")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ defmodule Oban.Web.ConnectivityComponent do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="connectivity" data-title={@title} phx-hook="Tippy">
|
||||
<Icons.bolt_circle :if={@status == :solitary} class="w-6 h-6 text-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.exclamation_circle
|
||||
:if={@status == :disconnected}
|
||||
|
|
|
|||
450
vendor/oban_web/lib/oban/web/live/crons/detail_component.ex
vendored
Normal 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
|
||||
56
vendor/oban_web/lib/oban/web/live/crons/helpers.ex
vendored
Normal 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
|
||||
332
vendor/oban_web/lib/oban/web/live/crons/new_component.ex
vendored
Normal 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
|
||||
254
vendor/oban_web/lib/oban/web/live/crons/table_component.ex
vendored
Normal 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">→</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
|
||||
55
vendor/oban_web/lib/oban/web/live/help_component.ex
vendored
Normal 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
|
||||
|
|
@ -38,7 +38,7 @@ defmodule Oban.Web.InstancesComponent do
|
|||
<button
|
||||
aria-expanded="true"
|
||||
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:bg-black/5 dark:hover:bg-white/5
|
||||
ring-1 ring-inset ring-gray-400 dark:ring-gray-700
|
||||
|
|
@ -53,7 +53,7 @@ defmodule Oban.Web.InstancesComponent do
|
|||
</button>
|
||||
|
||||
<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"
|
||||
>
|
||||
<li
|
||||
|
|
@ -85,7 +85,7 @@ defmodule Oban.Web.InstancesComponent do
|
|||
|
||||
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})
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -12,13 +12,15 @@ defmodule Oban.Web.Jobs.ChartComponent do
|
|||
|
||||
@impl Phoenix.LiveComponent
|
||||
def update(assigns, socket) do
|
||||
default_series = hd(series())
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(conf: assigns.conf, params: assigns.params)
|
||||
|> 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(: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)
|
||||
|
||||
socket =
|
||||
|
|
@ -45,7 +47,7 @@ defmodule Oban.Web.Jobs.ChartComponent do
|
|||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~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 id="chart-h" class="flex items-center text-gray-900 dark:text-gray-200">
|
||||
<button
|
||||
|
|
@ -127,9 +129,12 @@ defmodule Oban.Web.Jobs.ChartComponent do
|
|||
|
||||
<div
|
||||
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>
|
||||
"""
|
||||
|
|
@ -142,9 +147,11 @@ defmodule Oban.Web.Jobs.ChartComponent do
|
|||
cols = assigns.max_cols
|
||||
sy_time = Timing.snap(System.system_time(:second), step)
|
||||
|
||||
filters = params_to_filters(assigns.params)
|
||||
|
||||
opts = [
|
||||
by: step,
|
||||
filters: params_to_filters(assigns.params),
|
||||
filters: filters,
|
||||
group: assigns.group,
|
||||
lookback: cols * step,
|
||||
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(_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("full_count"), do: "Full Count"
|
||||
defp metric_label("exec_time"), do: "Execution Time"
|
||||
|
|
|
|||
106
vendor/oban_web/lib/oban/web/live/jobs/history_chart_component.ex
vendored
Normal 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
|
||||
290
vendor/oban_web/lib/oban/web/live/jobs/new_component.ex
vendored
Normal 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
|
||||
|
|
@ -42,42 +42,7 @@ defmodule Oban.Web.Jobs.SidebarComponent do
|
|||
active={active_filter?(@params, :queues, queue.name)}
|
||||
patch={patch_params(@params, :jobs, :queues, queue.name)}
|
||||
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.sidebar>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ defmodule Oban.Web.Jobs.TableComponent do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<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="queue" class="ml-auto text-right" />
|
||||
<.header label="time" class="w-20 pr-3 text-right" />
|
||||
|
|
@ -91,10 +91,14 @@ defmodule Oban.Web.Jobs.TableComponent do
|
|||
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">
|
||||
<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 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"
|
||||
id={"job-rescued-#{assigns.job.id}"}
|
||||
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)}
|
||||
class="h-5 w-5 text-gray-500 dark:text-gray-300"
|
||||
id={"job-orphaned-#{assigns.job.id}"}
|
||||
|
|
@ -197,14 +201,22 @@ defmodule Oban.Web.Jobs.TableComponent do
|
|||
|
||||
defp timestamp(job) do
|
||||
datetime =
|
||||
case job.state do
|
||||
"available" -> job.scheduled_at
|
||||
"cancelled" -> job.cancelled_at
|
||||
"completed" -> job.completed_at
|
||||
"discarded" -> job.discarded_at
|
||||
"executing" -> job.attempted_at
|
||||
"retryable" -> job.scheduled_at
|
||||
"scheduled" -> job.scheduled_at
|
||||
case job do
|
||||
%{state: state, scheduled_at: at}
|
||||
when state in ~w(available scheduled suspended retryable) ->
|
||||
at
|
||||
|
||||
%{state: "executing", attempted_at: at} ->
|
||||
at
|
||||
|
||||
%{state: "cancelled", cancelled_at: at} ->
|
||||
at
|
||||
|
||||
%{state: "completed", completed_at: at} ->
|
||||
at
|
||||
|
||||
%{state: "discarded", discarded_at: at} ->
|
||||
at
|
||||
end
|
||||
|
||||
if is_struct(datetime) do
|
||||
|
|
@ -230,5 +242,5 @@ defmodule Oban.Web.Jobs.TableComponent do
|
|||
"""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,147 +3,253 @@ defmodule Oban.Web.Jobs.TimelineComponent do
|
|||
|
||||
use Phoenix.Component
|
||||
|
||||
alias Oban.Web.Colors
|
||||
alias Oban.Web.Components.Icons
|
||||
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
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:path, compute_path(assigns.job))
|
||||
|> assign(:now, DateTime.from_unix!(assigns.os_time))
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={"timeline-for-#{@state}"}
|
||||
class="w-1/4 flex flex-col"
|
||||
data-title={timestamp_title(@state, @job)}
|
||||
phx-hook="Tippy"
|
||||
id="job-timeline"
|
||||
class="w-full"
|
||||
phx-hook="TimelineConnectors"
|
||||
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)}"}>
|
||||
<%= if timeline_icon(@state, @job) == :checkmark do %>
|
||||
<Icons.check class="w-12 h-12" />
|
||||
<% end %>
|
||||
<%= if timeline_icon(@state, @job) == :spinner do %>
|
||||
<svg class="h-12 w-12 animate-spin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
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"
|
||||
fill-rule="nonzero"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
</span>
|
||||
<span class="block text-sm text-center font-semibold mt-2">
|
||||
{timestamp_name(@state, @job)}
|
||||
</span>
|
||||
<span class="block text-sm text-center tabular">
|
||||
{timeline_time(@state, @job, @os_time)}
|
||||
<div id="timeline-boxes" class="relative">
|
||||
<svg id="timeline-connectors" class="absolute inset-0 overflow-visible pointer-events-none" />
|
||||
|
||||
<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">
|
||||
<.state_box state="suspended" job={@job} path={@path} now={@now} />
|
||||
<div></div>
|
||||
<div></div>
|
||||
<.state_box state="completed" job={@job} path={@path} now={@now} />
|
||||
|
||||
<.state_box state="scheduled" job={@job} path={@path} now={@now} />
|
||||
<.state_box state="available" job={@job} path={@path} now={@now} />
|
||||
<.state_box state="executing" job={@job} path={@path} now={@now} />
|
||||
<.state_box state="cancelled" job={@job} path={@path} now={@now} />
|
||||
|
||||
<.state_box state="retryable" job={@job} path={@path} now={@now} />
|
||||
<div></div>
|
||||
<div></div>
|
||||
<.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>
|
||||
</div>
|
||||
"""
|
||||
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
|
||||
case state do
|
||||
"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
|
||||
"#{@state_labels[state]}: #{truncate_sec(timestamp_for_state(state, job))}"
|
||||
end
|
||||
|
||||
defp timeline_class(state, job) do
|
||||
case absolute_state(state, job) do
|
||||
: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)
|
||||
defp truncate_sec(nil), do: "—"
|
||||
defp truncate_sec(datetime), do: DateTime.truncate(datetime, :second)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Oban.Web.Queues.DetailInsanceComponent do
|
||||
defmodule Oban.Web.Queues.DetailInstanceComponent do
|
||||
use Oban.Web, :live_component
|
||||
|
||||
import Oban.Web.Helpers.QueueHelper
|
||||
|
|
@ -14,9 +14,11 @@ defmodule Oban.Web.Queues.DetailInsanceComponent do
|
|||
socket =
|
||||
socket
|
||||
|> assign(access: assigns.access, checks: assigns.checks)
|
||||
|> assign_new(:queue, fn -> assigns.checks["queue"] end)
|
||||
|> assign_new(:paused, fn -> assigns.checks["paused"] end)
|
||||
|> assign(node_history: assigns[:node_history] || [])
|
||||
|> assign(queue: assigns.checks["queue"])
|
||||
|> assign(paused: assigns.checks["paused"])
|
||||
|> assign_new(:local_limit, fn -> assigns.checks["local_limit"] end)
|
||||
|> assign_new(:editing?, fn -> false end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
|
@ -24,74 +26,153 @@ defmodule Oban.Web.Queues.DetailInsanceComponent do
|
|||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<tr>
|
||||
<td class="pl-3 py-3">{node_name(@checks)}</td>
|
||||
<td class="text-right py-3">{executing_count(@checks)}</td>
|
||||
<td class="text-right py-3">{started_at(@checks)}</td>
|
||||
<td class="pl-9 py-3">
|
||||
<.pause_button
|
||||
disabled={not can?(:pause_queues, @access)}
|
||||
node={node_name(@checks)}
|
||||
paused={@paused}
|
||||
queue={@queue}
|
||||
target={@myself}
|
||||
/>
|
||||
<tr class={if @editing?, do: "bg-gray-50 dark:bg-gray-800"}>
|
||||
<td class="pl-3 py-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="truncate">{node_name(@checks)}</span>
|
||||
<.status_indicators checks={@checks} paused={@paused} />
|
||||
</div>
|
||||
</td>
|
||||
<td class="pr-3 py-3">
|
||||
<form
|
||||
id={"#{@checks["node"]}-form"}
|
||||
class="flex space-x-3"
|
||||
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"
|
||||
<%= if @editing? do %>
|
||||
<td colspan="5" class="pr-3 py-3">
|
||||
<form
|
||||
id={"#{@checks["node"]}-form"}
|
||||
class="flex items-center justify-end"
|
||||
phx-target={@myself}
|
||||
phx-submit="update"
|
||||
>
|
||||
Scale
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<input type="hidden" name="node" value={@checks["node"]} />
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<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>
|
||||
"""
|
||||
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
|
||||
assigns =
|
||||
assigns
|
||||
|> assign_new(:id, fn -> "#{assigns.queue}-toggle-pause" end)
|
||||
|> assign_new(:title, fn -> if assigns.paused, do: "Resume queue", else: "Pause queue" end)
|
||||
|> assign_new(:id, fn -> "#{assigns.node}-toggle-pause" end)
|
||||
|> assign_new(:tooltip, fn -> if assigns.paused, do: "Resume queue", else: "Pause queue" end)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
id={@id}
|
||||
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}
|
||||
phx-click="toggle-pause"
|
||||
phx-hook="Tippy"
|
||||
phx-target={@target}
|
||||
phx-value-queue={@queue}
|
||||
phx-value-node={@node}
|
||||
type="button"
|
||||
>
|
||||
<%= if @paused do %>
|
||||
<Icons.play_circle_solid class="w-5 h-5" />
|
||||
<Icons.play_circle class="w-5 h-5" />
|
||||
<% else %>
|
||||
<Icons.pause_circle_solid class="w-5 h-5" />
|
||||
<Icons.pause_circle class="w-5 h-5" />
|
||||
<% end %>
|
||||
</button>
|
||||
"""
|
||||
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
|
||||
def handle_event("toggle-pause", _params, socket) do
|
||||
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)}
|
||||
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
|
||||
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})
|
||||
|
||||
{:noreply, assign(socket, local_limit: limit)}
|
||||
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
|
||||
{:noreply, assign(socket, local_limit: limit, editing?: false)}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,42 +1,61 @@
|
|||
defmodule Oban.Web.Queues.TableComponent do
|
||||
use Oban.Web, :live_component
|
||||
|
||||
import Oban.Web.Helpers, only: [integer_to_estimate: 1, oban_path: 1]
|
||||
import Oban.Web.Helpers.QueueHelper
|
||||
|
||||
alias Oban.Web.{Queue, Timing}
|
||||
alias Oban.Web.Components.Core
|
||||
alias Oban.Web.Queue
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<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">
|
||||
<.queue_header label="name" class="ml-12 w-1/3 text-left" />
|
||||
<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/4 text-left" />
|
||||
<div class="ml-auto flex items-center space-x-6">
|
||||
<.queue_header label="nodes" class="w-16 text-right" />
|
||||
<.queue_header label="exec" class="w-16 text-right" />
|
||||
<.queue_header label="avail" class="w-16 text-right" />
|
||||
<.queue_header label="local" class="w-16 text-right" />
|
||||
<.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="utilization" class="w-56 text-center" />
|
||||
<.queue_header label="history" class="w-80 text-center" />
|
||||
<.queue_header label="pending" class="w-42 text-center" />
|
||||
<.queue_header label="nodes" class="w-20 text-center" />
|
||||
<.queue_header label="status" class="w-20 pr-3 text-right" />
|
||||
</div>
|
||||
</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">→</span>
|
||||
</a>
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<.queue_row
|
||||
:for={queue <- @queues}
|
||||
access={@access}
|
||||
history={Map.get(@history, queue.name, %{})}
|
||||
myself={@myself}
|
||||
queue={queue}
|
||||
selected={MapSet.member?(@selected, queue.name)}
|
||||
total_limit={Queue.local_limit(queue)}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -57,9 +76,11 @@ defmodule Oban.Web.Queues.TableComponent do
|
|||
end
|
||||
|
||||
attr :access, :map, required: true
|
||||
attr :history, :map, required: true
|
||||
attr :myself, :any, required: true
|
||||
attr :queue, :string, required: true
|
||||
attr :selected, :boolean, default: false
|
||||
attr :total_limit, :integer, required: true
|
||||
|
||||
defp queue_row(assigns) do
|
||||
~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">
|
||||
<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}
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center space-x-6 tabular text-gray-500 dark:text-gray-300">
|
||||
<span rel="nodes" class="w-16 text-right">
|
||||
<div class="ml-auto flex items-center space-x-6">
|
||||
<% {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)}
|
||||
</span>
|
||||
|
||||
<span rel="executing" class="w-16 text-right">
|
||||
{@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">
|
||||
<div class="w-20 pr-3 flex justify-center items-center space-x-1">
|
||||
<Icons.pause_circle
|
||||
:if={Queue.all_paused?(@queue)}
|
||||
class="w-4 h-4"
|
||||
class="w-5 h-5"
|
||||
data-title="All paused"
|
||||
id={"#{@queue.name}-is-paused"}
|
||||
phx-hook="Tippy"
|
||||
|
|
@ -119,7 +213,7 @@ defmodule Oban.Web.Queues.TableComponent do
|
|||
/>
|
||||
<Icons.play_pause_circle
|
||||
: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"
|
||||
id={"#{@queue.name}-is-some-paused"}
|
||||
phx-hook="Tippy"
|
||||
|
|
@ -127,7 +221,7 @@ defmodule Oban.Web.Queues.TableComponent do
|
|||
/>
|
||||
<Icons.power
|
||||
:if={Queue.terminating?(@queue)}
|
||||
class="w-4 h-4"
|
||||
class="w-5 h-5"
|
||||
data-title="Terminating"
|
||||
id={"#{@queue.name}-is-terminating"}
|
||||
phx-hook="Tippy"
|
||||
|
|
@ -151,54 +245,10 @@ defmodule Oban.Web.Queues.TableComponent do
|
|||
|
||||
# Helpers
|
||||
|
||||
defp local_limit(queue) do
|
||||
queue.checks
|
||||
|> Enum.map(& &1["local_limit"])
|
||||
|> Enum.min_max()
|
||||
|> case do
|
||||
{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)
|
||||
defp utilization(queue) do
|
||||
exec = executing_count(queue.checks)
|
||||
limit = Queue.local_limit(queue)
|
||||
percent = if limit > 0, do: min(round(exec / limit * 100), 100), else: 0
|
||||
{exec, limit, percent}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ defmodule Oban.Web.RefreshComponent do
|
|||
{02, "2s"},
|
||||
{05, "5s"},
|
||||
{15, "15s"},
|
||||
{30, "30s"},
|
||||
{60, "1m"},
|
||||
{-1, "Off"}
|
||||
]
|
||||
|
|
@ -28,7 +29,7 @@ defmodule Oban.Web.RefreshComponent do
|
|||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
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"
|
||||
id="refresh-menu-toggle"
|
||||
phx-hook="Tippy"
|
||||
|
|
@ -40,7 +41,7 @@ defmodule Oban.Web.RefreshComponent do
|
|||
</button>
|
||||
|
||||
<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"
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ defmodule Oban.Web.SearchComponent do
|
|||
aria-label="Add filters"
|
||||
aria-placeholder="Add filters"
|
||||
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"
|
||||
name="terms"
|
||||
phx-blur={hide_focus()}
|
||||
|
|
@ -132,7 +132,7 @@ defmodule Oban.Web.SearchComponent do
|
|||
</span>
|
||||
|
||||
<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"
|
||||
phx-click="remove-filter"
|
||||
phx-value-param={@param}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ defmodule Oban.Web.ShortcutsComponent do
|
|||
<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 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="Toggle refresh" shortcut="r" />
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ defmodule Oban.Web.ThemeComponent do
|
|||
<button
|
||||
aria-expanded="true"
|
||||
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"
|
||||
id="theme-menu-toggle"
|
||||
phx-hook="Tippy"
|
||||
|
|
@ -24,7 +24,7 @@ defmodule Oban.Web.ThemeComponent do
|
|||
</button>
|
||||
|
||||
<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"
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
|
|
|
|||
606
vendor/oban_web/lib/oban/web/live/workflows/detail_component.ex
vendored
Normal 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
|
||||
271
vendor/oban_web/lib/oban/web/live/workflows/table_component.ex
vendored
Normal 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">→</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
|
|
@ -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
|
||||
264
vendor/oban_web/lib/oban/web/pages/crons_page.ex
vendored
Normal 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
|
||||
166
vendor/oban_web/lib/oban/web/pages/jobs_page.ex
vendored
|
|
@ -4,16 +4,21 @@ defmodule Oban.Web.JobsPage do
|
|||
use Oban.Web, :live_component
|
||||
|
||||
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.{JobQuery, Page, QueueQuery, SearchComponent, SortComponent, Telemetry}
|
||||
|
||||
@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
|
||||
def update(assigns, socket) do
|
||||
{:ok, assign(socket, assigns)}
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:show_new_form, fn -> false end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
|
|
@ -21,6 +26,7 @@ defmodule Oban.Web.JobsPage do
|
|||
~H"""
|
||||
<div id="jobs-page" class="flex-1 w-full flex flex-col my-6 md:flex-row">
|
||||
<SidebarComponent.sidebar
|
||||
:if={is_nil(@detailed)}
|
||||
nodes={@nodes}
|
||||
params={without_defaults(@params, @default_params)}
|
||||
queues={@queues}
|
||||
|
|
@ -40,15 +46,24 @@ defmodule Oban.Web.JobsPage do
|
|||
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 %>
|
||||
<.live_component
|
||||
id="detail"
|
||||
access={@access}
|
||||
conf={@conf}
|
||||
diagnostics={@diagnostics}
|
||||
diagnostics_at={@diagnostics_at}
|
||||
history={@history}
|
||||
init_state={@init_state}
|
||||
job={@detailed}
|
||||
module={DetailComponent}
|
||||
os_time={@os_time}
|
||||
params={without_defaults(Map.delete(@params, "id"), @default_params)}
|
||||
queues={@queues}
|
||||
resolver={@resolver}
|
||||
/>
|
||||
<% else %>
|
||||
|
|
@ -121,7 +136,7 @@ defmodule Oban.Web.JobsPage do
|
|||
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">
|
||||
{MapSet.size(@selected)} Selected
|
||||
</span>
|
||||
|
|
@ -131,6 +146,24 @@ defmodule Oban.Web.JobsPage do
|
|||
params={@params}
|
||||
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>
|
||||
|
||||
|
|
@ -146,6 +179,15 @@ defmodule Oban.Web.JobsPage do
|
|||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.live_component
|
||||
:if={@show_new_form}
|
||||
id="new-job-form"
|
||||
access={@access}
|
||||
conf={@conf}
|
||||
module={NewComponent}
|
||||
queues={@queues}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
@ -163,6 +205,9 @@ defmodule Oban.Web.JobsPage do
|
|||
%{socket | assigns: assigns}
|
||||
|> assign_new(:default_params, default)
|
||||
|> 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(:nodes, fn -> [] end)
|
||||
|> assign_new(:os_time, fn -> System.os_time(:second) end)
|
||||
|
|
@ -189,29 +234,68 @@ defmodule Oban.Web.JobsPage do
|
|||
MapSet.new()
|
||||
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,
|
||||
detailed: JobQuery.refresh_job(conf, socket.assigns.detailed),
|
||||
detailed: detailed,
|
||||
diagnostics: diagnostics,
|
||||
diagnostics_at: diagnostics_at,
|
||||
history: history,
|
||||
jobs: jobs,
|
||||
nodes: nodes(conf),
|
||||
os_time: System.os_time(:second),
|
||||
queues: queues(conf),
|
||||
queues: queues(conf, socket.assigns.queues),
|
||||
selected: selected,
|
||||
states: states(conf)
|
||||
states: states(conf, socket.assigns.states)
|
||||
)
|
||||
end
|
||||
|
||||
@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)
|
||||
|
||||
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 ->
|
||||
{:noreply, push_patch(socket, to: oban_path(:jobs), replace: true)}
|
||||
|
||||
job ->
|
||||
Oban.Notifier.listen(conf.name, [:diagnostics_reply])
|
||||
|
||||
history = JobQuery.job_history(job, conf)
|
||||
|
||||
{:noreply,
|
||||
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)}
|
||||
end
|
||||
end
|
||||
|
|
@ -219,14 +303,22 @@ defmodule Oban.Web.JobsPage do
|
|||
def handle_params(params, _uri, socket) do
|
||||
%{conf: conf, resolver: resolver} = socket.assigns
|
||||
|
||||
Oban.Notifier.unlisten(conf.name, [:diagnostics_reply])
|
||||
|
||||
params = params_with_defaults(params, 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(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}
|
||||
end
|
||||
|
|
@ -281,6 +373,20 @@ defmodule Oban.Web.JobsPage do
|
|||
{:noreply, socket}
|
||||
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
|
||||
|
||||
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)}
|
||||
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
|
||||
|
||||
def handle_info({:toggle_select, job_id}, socket) do
|
||||
|
|
@ -466,15 +589,14 @@ defmodule Oban.Web.JobsPage do
|
|||
|> Enum.sort_by(& &1.name)
|
||||
end
|
||||
|
||||
def states(conf) do
|
||||
counts = Met.latest(conf.name, :full_count, group: "state")
|
||||
|
||||
for state <- @ordered_states do
|
||||
%{name: state, count: Map.get(counts, state, 0)}
|
||||
end
|
||||
defp states(conf, previous) do
|
||||
Metrics.state_counts(conf.name, @ordered_states, previous)
|
||||
end
|
||||
|
||||
def queues(conf) do
|
||||
QueueQuery.all_queues(%{}, conf)
|
||||
defp queues(conf, previous) do
|
||||
previous_counts = Metrics.extract_queue_counts(previous)
|
||||
counts = Metrics.all_queue_counts(conf.name, previous_counts)
|
||||
|
||||
QueueQuery.all_queues(%{}, conf, counts)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
162
vendor/oban_web/lib/oban/web/pages/queues_page.ex
vendored
|
|
@ -4,25 +4,21 @@ defmodule Oban.Web.QueuesPage do
|
|||
use Oban.Web, :live_component
|
||||
|
||||
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}
|
||||
alias Oban.Web.Queues.{SidebarComponent, TableComponent}
|
||||
alias Oban.Web.{Page, QueueQuery, SearchComponent, SortComponent, Telemetry}
|
||||
@inc_limit 20
|
||||
@max_limit 100
|
||||
@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
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="queues-page" class="w-full flex flex-col my-6 md:flex-row">
|
||||
<SidebarComponent.sidebar
|
||||
queues={@queues}
|
||||
params={without_defaults(@params, @default_params)}
|
||||
width={@sidebar_width}
|
||||
csp_nonces={@csp_nonces}
|
||||
/>
|
||||
|
||||
<div class="flex-grow">
|
||||
<div id="queues-page" class="w-full my-6">
|
||||
<div>
|
||||
<div class="bg-white dark:bg-gray-900 rounded-md shadow-lg overflow-hidden">
|
||||
<%= if @detail do %>
|
||||
<.live_component
|
||||
|
|
@ -31,6 +27,7 @@ defmodule Oban.Web.QueuesPage do
|
|||
conf={@conf}
|
||||
checks={@checks}
|
||||
module={DetailComponent}
|
||||
node_history={@node_history}
|
||||
queue={@detail}
|
||||
/>
|
||||
<% else %>
|
||||
|
|
@ -110,7 +107,7 @@ defmodule Oban.Web.QueuesPage do
|
|||
id="queues-sort"
|
||||
by={~w(name nodes avail exec local global rate_limit started)}
|
||||
page={:queues}
|
||||
params={@params}
|
||||
params={sort_params(@params, @default_params)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -119,10 +116,20 @@ defmodule Oban.Web.QueuesPage do
|
|||
id="queues-table"
|
||||
module={TableComponent}
|
||||
access={@access}
|
||||
checks={@checks}
|
||||
history={@history}
|
||||
params={@params}
|
||||
queues={@queues}
|
||||
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 %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -130,39 +137,113 @@ defmodule Oban.Web.QueuesPage do
|
|||
"""
|
||||
end
|
||||
|
||||
@keep_on_mount ~w(checks counts default_params detail params queues selected)a
|
||||
|
||||
@impl Page
|
||||
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 =
|
||||
Map.drop(socket.assigns, @keep_on_mount)
|
||||
|
||||
%{socket | assigns: assigns}
|
||||
|> 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(:detail, fn -> nil end)
|
||||
|> assign_new(:history, fn -> %{} end)
|
||||
|> assign_new(:node_history, fn -> %{} end)
|
||||
|> assign_new(:params, default)
|
||||
|> assign_new(:queues, fn -> [] end)
|
||||
|> assign_new(:selected, &MapSet.new/0)
|
||||
|> assign_new(:show_less?, fn -> false end)
|
||||
|> assign_new(:show_more?, fn -> false end)
|
||||
end
|
||||
|
||||
@impl Page
|
||||
def handle_refresh(socket) do
|
||||
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
|
||||
|
||||
defp checks(conf) do
|
||||
Met.checks(conf.name)
|
||||
end
|
||||
|
||||
defp counts(conf) do
|
||||
Met.latest(conf.name, :full_count, group: "queue", filters: [state: "available"])
|
||||
defp queue_history(conf) do
|
||||
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
|
||||
|
||||
defp select_mode(checks, selected) do
|
||||
|
|
@ -228,6 +309,22 @@ defmodule Oban.Web.QueuesPage do
|
|||
{:noreply, socket}
|
||||
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
|
||||
def handle_info({:pause_queue, queue}, socket) do
|
||||
Telemetry.action(:pause_queue, socket, [queue: queue], fn ->
|
||||
|
|
@ -312,6 +409,16 @@ defmodule Oban.Web.QueuesPage do
|
|||
{:noreply, socket}
|
||||
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
|
||||
selected = socket.assigns.selected
|
||||
|
||||
|
|
@ -360,13 +467,22 @@ defmodule Oban.Web.QueuesPage do
|
|||
|
||||
if Keyword.has_key?(opts, :limit) 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
|
||||
|
||||
{:noreply, put_flash_with_clear(socket, :info, scale_message(queue, opts))}
|
||||
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
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
|
|
|||
336
vendor/oban_web/lib/oban/web/pages/workflows_page.ex
vendored
Normal 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
|
||||
353
vendor/oban_web/lib/oban/web/queries/cron_query.ex
vendored
Normal 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
|
||||
|
|
@ -2,6 +2,7 @@ defmodule Oban.Web.JobQuery do
|
|||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
import Oban.Web.QueryHelpers
|
||||
|
||||
alias Oban.{Config, Job, Repo}
|
||||
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})
|
||||
|
||||
defguardp is_mysql(conf) when conf.engine == Oban.Engines.Dolphin
|
||||
|
||||
defguardp is_sqlite(conf) when conf.engine == Oban.Engines.Lite
|
||||
@history_limit 60
|
||||
|
||||
defmacrop json_table(field) do
|
||||
quote do
|
||||
|
|
@ -105,40 +104,6 @@ defmodule Oban.Web.JobQuery do
|
|||
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
|
||||
# less than 0. There's no standard `greatest/max` function that can clamp to 0, so we use a case
|
||||
# statement instead.
|
||||
|
|
@ -494,6 +459,33 @@ defmodule Oban.Web.JobQuery do
|
|||
|
||||
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
|
||||
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})
|
||||
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})
|
||||
end
|
||||
|
||||
|
|
|
|||
47
vendor/oban_web/lib/oban/web/queries/query_helpers.ex
vendored
Normal 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
|
||||
|
|
@ -7,7 +7,7 @@ defmodule Oban.Web.QueueQuery do
|
|||
@suggest_qualifier [
|
||||
{"nodes:", "host name", "nodes:machine@somehost"},
|
||||
{"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 [
|
||||
|
|
@ -92,22 +92,23 @@ defmodule Oban.Web.QueueQuery do
|
|||
|
||||
# Querying
|
||||
|
||||
def all_queues(params, %{name: name}) do
|
||||
{sort_by, sort_dir} = atomize_sort(params)
|
||||
def all_queues(params, conf, counts \\ %{})
|
||||
|
||||
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())
|
||||
|
||||
counts = %{
|
||||
available: Met.latest(name, :full_count, group: "queue", filters: [state: "available"]),
|
||||
executing: Met.latest(name, :full_count, group: "queue", filters: [state: "executing"])
|
||||
}
|
||||
queues =
|
||||
name
|
||||
|> 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
|
||||
|> 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)
|
||||
if limit, do: Enum.take(queues, limit), else: queues
|
||||
end
|
||||
|
||||
defp new({name, checks}, counts) do
|
||||
|
|
@ -116,11 +117,11 @@ defmodule Oban.Web.QueueQuery do
|
|||
struct!(Queue, %{name: name, checks: checks, counts: counts})
|
||||
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)}
|
||||
end
|
||||
|
||||
defp atomize_sort(_params), do: {:name, :asc}
|
||||
defp parse_sort(_params), do: {:name, :asc}
|
||||
|
||||
# Filtering
|
||||
|
||||
|
|
|
|||
473
vendor/oban_web/lib/oban/web/queries/workflow_query.ex
vendored
Normal 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
|
||||
6
vendor/oban_web/lib/oban/web/queue.ex
vendored
|
|
@ -56,6 +56,12 @@ defmodule Oban.Web.Queue do
|
|||
Enum.any?(checks, &is_map(&1["rate_limit"]))
|
||||
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
|
||||
Enum.any?(checks, & &1["shutdown_started_at"])
|
||||
end
|
||||
|
|
|
|||
27
vendor/oban_web/lib/oban/web/resolver.ex
vendored
|
|
@ -125,7 +125,19 @@ defmodule Oban.Web.Resolver do
|
|||
@type access :: :all | :read_only | [{access_option(), boolean()}] | {:forbidden, Path.t()}
|
||||
|
||||
@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
|
||||
|
||||
|
|
@ -258,12 +270,19 @@ defmodule Oban.Web.Resolver do
|
|||
|
||||
The available fine grained access controls are:
|
||||
|
||||
* `:cancel_jobs`
|
||||
* `:cancel_workflows`
|
||||
* `:delete_crons`
|
||||
* `:delete_jobs`
|
||||
* `:insert_crons`
|
||||
* `:insert_jobs`
|
||||
* `:pause_crons`
|
||||
* `:pause_queues`
|
||||
* `:retry_jobs`
|
||||
* `:retry_workflows`
|
||||
* `:scale_queues`
|
||||
* `:stop_queues`
|
||||
* `:cancel_jobs`
|
||||
* `:delete_jobs`
|
||||
* `:retry_jobs`
|
||||
* `:update_crons`
|
||||
|
||||
Actions which aren't listed are considered disabled.
|
||||
|
||||
|
|
|
|||
2
vendor/oban_web/lib/oban/web/router.ex
vendored
|
|
@ -199,6 +199,8 @@ defmodule Oban.Web.Router do
|
|||
live_session session_name, session_opts do
|
||||
get "/css-:md5", Oban.Web.Assets, :css, as: :oban_web_asset
|
||||
get "/js-:md5", Oban.Web.Assets, :js, as: :oban_web_asset
|
||||
get "/fonts/inter.woff2", Oban.Web.Assets, :font, as: :oban_web_font
|
||||
get "/icons/*path", Oban.Web.Assets, :icon, as: :oban_web_icon
|
||||
|
||||
live "/", Oban.Web.DashboardLive, :home, route_opts
|
||||
live "/:page", Oban.Web.DashboardLive, :index, route_opts
|
||||
|
|
|
|||
21
vendor/oban_web/lib/oban/web/timezones.ex
vendored
Normal 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
|
||||
18
vendor/oban_web/lib/oban/web/timing.ex
vendored
|
|
@ -138,7 +138,7 @@ defmodule Oban.Web.Timing do
|
|||
iex> Oban.Web.Timing.to_words(-1, relative: false)
|
||||
"1s"
|
||||
"""
|
||||
def to_words(ellapsed, opts \\ [relative: true]) when is_integer(ellapsed) do
|
||||
def to_words(ellapsed, opts \\ []) when is_integer(ellapsed) do
|
||||
distance =
|
||||
case abs(ellapsed) do
|
||||
0 -> "now"
|
||||
|
|
@ -151,7 +151,7 @@ defmodule Oban.Web.Timing do
|
|||
end
|
||||
|
||||
cond do
|
||||
not opts[:relative] -> distance
|
||||
opts[:relative] == false -> distance
|
||||
ellapsed < 0 -> "#{distance} ago"
|
||||
ellapsed > 0 -> "in #{distance}"
|
||||
true -> distance
|
||||
|
|
@ -252,14 +252,20 @@ defmodule Oban.Web.Timing do
|
|||
defp truncate_sec(datetime), do: NaiveDateTime.truncate(datetime, :second)
|
||||
|
||||
@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)
|
||||
|
||||
datetime_to_words(datetime, opts)
|
||||
end
|
||||
|
||||
def datetime_to_words(datetime, opts) do
|
||||
datetime
|
||||
|> NaiveDateTime.diff(now)
|
||||
|> to_words()
|
||||
|> NaiveDateTime.diff(NaiveDateTime.utc_now())
|
||||
|> to_words(opts)
|
||||
end
|
||||
|
||||
defp pad(time, places \\ 2) do
|
||||
|
|
|
|||
36
vendor/oban_web/lib/oban/web/utils.ex
vendored
Normal 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
|
||||
35
vendor/oban_web/lib/oban/web/workflow.ex
vendored
Normal 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
|
||||
33
vendor/oban_web/mix.exs
vendored
|
|
@ -2,7 +2,7 @@ defmodule Oban.Web.MixProject do
|
|||
use Mix.Project
|
||||
|
||||
@source_url "https://github.com/oban-bg/oban_web"
|
||||
@version "2.11.8"
|
||||
@version "2.12.1"
|
||||
|
||||
def project do
|
||||
[
|
||||
|
|
@ -17,7 +17,7 @@ defmodule Oban.Web.MixProject do
|
|||
listeners: [Phoenix.CodeReloader],
|
||||
package: package(),
|
||||
name: "Oban Web",
|
||||
description: "Dashboard for the Oban job processing framework"
|
||||
description: "Dashboard for the Oban job orchestration framework"
|
||||
]
|
||||
end
|
||||
|
||||
|
|
@ -30,7 +30,14 @@ defmodule Oban.Web.MixProject do
|
|||
end
|
||||
|
||||
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
|
||||
|
||||
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||
|
|
@ -40,7 +47,9 @@ defmodule Oban.Web.MixProject do
|
|||
[
|
||||
maintainers: ["Parker Selbert"],
|
||||
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: %{
|
||||
Website: "https://oban.pro",
|
||||
Changelog: "#{@source_url}/blob/main/CHANGELOG.md",
|
||||
|
|
@ -92,8 +101,8 @@ defmodule Oban.Web.MixProject do
|
|||
{:phoenix_live_view, "~> 1.0"},
|
||||
|
||||
# Oban
|
||||
{:oban, "~> 2.19"},
|
||||
{:oban_met, "~> 1.0"},
|
||||
{:oban, "~> 2.21"},
|
||||
{:oban_met, "~> 1.1"},
|
||||
|
||||
# Databases
|
||||
{:ecto_sqlite3, "~> 0.18", only: [:dev, :test]},
|
||||
|
|
@ -106,11 +115,12 @@ defmodule Oban.Web.MixProject do
|
|||
{:faker, "~> 0.17", only: :dev},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:tailwind, "~> 0.4", only: :dev, runtime: false},
|
||||
{:tz, "~> 0.24", only: [:test, :dev]},
|
||||
|
||||
# Tooling
|
||||
{:credo, "~> 1.7", only: [:test, :dev], runtime: false},
|
||||
{:floki, "~> 0.33", only: [:test, :dev]},
|
||||
{:igniter, "~> 0.5", only: [:dev, :test]},
|
||||
{:lazy_html, ">= 0.1.0", only: :test},
|
||||
|
||||
# Docs and Publishing
|
||||
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
|
||||
|
|
@ -120,7 +130,7 @@ defmodule Oban.Web.MixProject do
|
|||
|
||||
defp oban_pro_dep 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
|
||||
[]
|
||||
end
|
||||
|
|
@ -142,6 +152,7 @@ defmodule Oban.Web.MixProject do
|
|||
[
|
||||
"assets.build": ["tailwind default", "esbuild default"],
|
||||
dev: "run --no-halt dev.exs",
|
||||
precommit: ["test.ci"],
|
||||
release: [
|
||||
"assets.build",
|
||||
"cmd git tag v#{@version} -f",
|
||||
|
|
@ -149,6 +160,9 @@ defmodule Oban.Web.MixProject do
|
|||
"cmd git push --tags",
|
||||
"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.setup": ["ecto.create --quiet", "ecto.migrate --quiet"],
|
||||
"test.ci": [
|
||||
|
|
@ -156,8 +170,7 @@ defmodule Oban.Web.MixProject do
|
|||
"deps.unlock --check-unused",
|
||||
"credo --strict",
|
||||
"test --raise"
|
||||
],
|
||||
"dash.build": "cmd docker build -t oban-dash standalone"
|
||||
]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
2
vendor/oban_web/priv/static/app.css
vendored
170
vendor/oban_web/priv/static/app.js
vendored
BIN
vendor/oban_web/priv/static/fonts/Inter.woff2
vendored
Normal file
3
vendor/oban_web/priv/static/icons/outline/adjustments-horizontal.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/adjustments-vertical.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/arrow-left.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/arrow-path-rounded.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/arrow-path.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/arrow-right-circle.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/arrow-right.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/arrow-top-right-on-square.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/arrow-trending-down.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/arrow-turn-down-right.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/bars-arrow-down.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/bars-arrow-up.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/bolt-circle.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/bolt-slash.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/calendar-days.svg
vendored
Normal 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 |
4
vendor/oban_web/priv/static/icons/outline/camera.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/chart-bar-square.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/check-circle.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/check.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/chevron-down.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/chevron-left.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/chevron-right.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/chevron-up.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/clipboard.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/clock.svg
vendored
Normal 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 |
4
vendor/oban_web/priv/static/icons/outline/cog-8-tooth.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/cog.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/command-line.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/computer-desktop.svg
vendored
Normal 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 |
3
vendor/oban_web/priv/static/icons/outline/ellipsis-horizontal-circle.svg
vendored
Normal 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 |