Mount Oban Web dashboard at /admin/oban

Vendor oban_web 2.12.1 and oban_met 1.1.0 (taken from the
towerops-web2 vendor tree), bump oban to ~> 2.21, and mount the Oban
dashboard at /admin/oban behind the existing require_admin on_mount
hook. Adds an admin-only "Oban" nav link and extends the Dockerfile
to copy vendor/ before deps.get so production builds pick up the
path dependencies.
This commit is contained in:
Graham McIntire 2026-04-09 14:07:24 -05:00
parent 917e82311a
commit caad7d90a7
166 changed files with 18616 additions and 1 deletions

View file

@ -61,6 +61,7 @@ ENV MIX_ENV="prod"
# install mix dependencies (changes only when mix.exs/mix.lock change) # install mix dependencies (changes only when mix.exs/mix.lock change)
COPY mix.exs mix.lock ./ COPY mix.exs mix.lock ./
COPY vendor vendor
RUN mix deps.get --only $MIX_ENV RUN mix deps.get --only $MIX_ENV
# copy compile-time config before compiling deps # copy compile-time config before compiling deps

View file

@ -54,6 +54,13 @@ defmodule MicrowavepropWeb.Layouts do
> >
Users Users
</.link> </.link>
<.link
:if={@current_scope && @current_scope.user && @current_scope.user.is_admin}
href="/admin/oban"
class="btn btn-ghost btn-sm"
>
Oban
</.link>
<%= if @current_scope && @current_scope.user do %> <%= if @current_scope && @current_scope.user do %>
<span class="btn btn-ghost btn-sm pointer-events-none opacity-70"> <span class="btn btn-ghost btn-sm pointer-events-none opacity-70">
{@current_scope.user.callsign} {@current_scope.user.callsign}

View file

@ -2,6 +2,7 @@ defmodule MicrowavepropWeb.Router do
use MicrowavepropWeb, :router use MicrowavepropWeb, :router
import MicrowavepropWeb.UserAuth import MicrowavepropWeb.UserAuth
import Oban.Web.Router
import Phoenix.LiveDashboard.Router import Phoenix.LiveDashboard.Router
pipeline :browser do pipeline :browser do
@ -102,6 +103,8 @@ defmodule MicrowavepropWeb.Router do
ecto_repos: [Microwaveprop.Repo], ecto_repos: [Microwaveprop.Repo],
ecto_psql_extras_options: [long_running_queries: [threshold: "200 milliseconds"]], ecto_psql_extras_options: [long_running_queries: [threshold: "200 milliseconds"]],
on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}]
oban_dashboard "/admin/oban", on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}]
end end
# Enable Swoosh mailbox preview in development # Enable Swoosh mailbox preview in development

View file

@ -66,7 +66,9 @@ defmodule Microwaveprop.MixProject do
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:bandit, "~> 1.5"}, {:bandit, "~> 1.5"},
{:styler, "~> 1.11", only: [:dev, :test], runtime: false}, {:styler, "~> 1.11", only: [:dev, :test], runtime: false},
{:oban, "~> 2.19"}, {:oban, "~> 2.21"},
{:oban_met, "~> 1.0", path: "vendor/oban_met", override: true},
{:oban_web, "~> 2.12", path: "vendor/oban_web"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:nx, "~> 0.9", only: [:dev, :test]}, {:nx, "~> 0.9", only: [:dev, :test]},
{:axon, "~> 0.7", only: [:dev, :test]}, {:axon, "~> 0.7", only: [:dev, :test]},

4
vendor/oban_met/.formatter.exs vendored Normal file
View file

@ -0,0 +1,4 @@
[
import_deps: [:ecto_sql, :oban, :stream_data],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

BIN
vendor/oban_met/.hex vendored Normal file

Binary file not shown.

113
vendor/oban_met/CHANGELOG.md vendored Normal file
View file

@ -0,0 +1,113 @@
# 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
- [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
compatibility.
Setting `config :oban_met, sketch_time_unit: :millisecond` reduces sketch storage size by ~20%
and consolidates timing values into fewer bins by eliminating nanosecond-level measurement
noise.
- [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.
## v1.0.5 — 2025-12-10
### Bug Fixes
- [Examiner] Normalize `limit` to `local_limit` for web consistency
Oban checks return `limit`, while Pro stores them as `local_limit`. That causes inconsistent
data when displaying OSS queue details in `oban_web`. This stores `limit` as `local_limit`
consistently, rather than checking for both keys.
## v1.0.4 — 2025-11-26
### Bug Fixes
- [Met] Match on config to auto-start named instances
The registry pattern only matched `Oban` instances, not modules created with `use Oban`. This
modifies the pattern to extract `Oban.Config` structs directly.
- [Met] Remove use of struct update syntax deprecated in Elixir v1.19
## v1.0.3 — 2025-05-06
### Enhancements
- [Examiner] Use parallel execution for health checks.
Minimize total time taken to scrape checks from producers by parallelizing calls. The worst case
time is now O(1) rather than O(n) when producers are busy.
## v1.0.2 — 2025-03-14
This release requires Oban v2.19 because of a dependency on the new JSON module.
### Bug Fixes
- [Met] Log unexpected messages from trapping servers
The `reporter` and `cronitor` have a small set of messages they expect. Rather than crashing on
unexpected messages, the processes now log a warning instead.
- [Meta] Override application options with passed options
Passed options weren't relayed to the underlying module and only application level options had
any effect. This ensures options from both locations are merged.
- [Met] Use conditional JSON derive and Oban.JSON wrapper
## v1.0.1 — 2025-01-16
### Bug Fixes
- [Value] Implement `JSON.Encoder` for value types
The encoder is necessary to work with official JSON encoded values as well as legacy Jason.
## v1.0.0 — 2025-01-16
This is the official v1.0 release _and_ the package is now open source!
### Enhancements
- [Reporter] Count queries work with all official Oban engines and databases (Postgres, SQLite,
and MySQL).
- [Migration] Add ability to explicitly create estimate function through a dedicated migration.
For databases that don't allow the application connect to create new functions, this adds an
`Oban.Met.Migration` module and an option to disable auto-migrating in the reporter.
### Bug Fixes
- [Reporter] Skip queries with empty states or queues to prevent query errors.
Querying with an empty list may cause the following error:
```
(Postgrex.Error) ERROR 42809 (wrong_object_type) op ANY/ALL (array)
```
This fixes the error by skipping queries for empty lists. This could happen if all of the states
were above the estimate threshold, or no active queues were found.

202
vendor/oban_met/LICENSE.txt vendored Normal file
View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 The Oban Team
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

192
vendor/oban_met/README.md vendored Normal file
View file

@ -0,0 +1,192 @@
# Oban Met
<p align="center">
<a href="https://hex.pm/packages/oban_met">
<img alt="Hex Version" src="https://img.shields.io/hexpm/v/oban_met.svg" />
</a>
<a href="https://hexdocs.pm/oban_met">
<img alt="Hex Docs" src="http://img.shields.io/badge/hex.pm-docs-green.svg?style=flat" />
</a>
<a href="https://github.com/oban-bg/oban_met/actions">
<img alt="CI Status" src="https://github.com/oban-bg/oban_met/actions/workflows/ci.yml/badge.svg" />
</a>
<a href="https://opensource.org/licenses/Apache-2.0">
<img alt="Apache 2 License" src="https://img.shields.io/hexpm/l/oban_met" />
</a>
</p>
<!-- MDOC -->
Met is a distributed, compacting, multidimensional, telemetry-powered time series datastore for
Oban that requires no configuration. It gathers data for queues, job counts, execution metrics,
active crontabs, historic metrics, and more.
Met powers the charts and runtime details shown in the [Oban Web][web] dashboard.
[web]: https://github.com/oban-bg/oban_web
## Features
- **🤖 Autonomous** - Supervises a collection of autonomous modules that dynamically start and stop alongside
Oban instances without any code changes.
- **🎩 Distributed** - Metrics are shared between all connected nodes via pubsub. Leadership is used
to restrict expensive operations, such as performing counts, to a single node.
- **📼 Recorded** - Telemetry events and scraped data are stored in-memory as time series data.
Values are stored as either gauges or space efficient "sketches".
- **🪐 Multidimensional** - Metrics are stored with labels such as `node`, `queue`, `worker`, etc.
that can be filtered and grouped dynamically at runtime.
- **🗜️ Compacting** - Time series values are periodically compacted into larger windows of time to
save space and optimize querying historic data. Compaction periods use safe defaults, but are
configurable.
- **✏️ Estimating** - In supporting systems (Postgres), count queries use optimized estimates
automatically for tables with a large number of jobs.
- **🔎 Queryable** - Historic metrics may be filtered and grouped by any label, sliced by
arbitrary time intervals, and numeric values aggregated at dynamic percentiles (e.g. P50, P99)
without pre-computed histogram buckets.
- **🤝 Handoff** - Ephemeral data storage via data replication with handoff between nodes. All nodes have a
shared view of the cluster's data and new nodes are caught up when they come online.
## Installation
Oban Met is included with Oban Web and manual installation is only necessary in hybrid
environments (separate Web and Worker nodes).
To receive metrics from non-web nodes in a system with separate "web" and "worker" applications
you must explicitly include `oban_met` as a dependency for "workers".
```elixir
{:oban_met, "~> 1.0"}
```
## Usage
No configuration is necessary and Oban Met will start automatically in a typical application. A
variety of options are provided for more complex or nuanced usage.
### Auto Start
Supervised Met instances start automatically along with Oban instances unless Oban is in testing
mode. You can disable auto-starting globally with application configuration:
```elixir
config :oban_met, auto_start: false
```
Then, start instances as a child directly within your Oban app's plugins:
```elixir
plugins: [
Oban.Met,
...
]
```
### Customizing Estimates
Options for internal `Oban.Met` processes can be overridden from the plugin specification. Most
options are internal and not meant to be overridden, but one particularly useful option to tune is
the `estimate_limit`. The `estimate_limit` determines at which point state/queue counts switch
from using an accurate `count(*)` call to a much more efficient, but less accurate, estimate
function.
The default limit is a conservative 50k, which may be too low for systems with insert spikes. This
declares an override to set the limit to 200k:
```elixir
{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
necessary under normal circumstances, but is provided to avoid permission issues or allow full
control over database changes.
```bash
mix ecto.gen.migration add_oban_met
```
Open the generated migration and delegate the `up/0` and `down/0` functions to
`Oban.Met.Migration`:
```elixir
defmodule MyApp.Repo.Migrations.AddObanMet do
use Ecto.Migration
def up, do: Oban.Met.Migration.up()
def down, do: Oban.Met.Migration.down()
end
```
Then, after disabling auto-start, configure the reporter not to auto-migrate if you run the
explicit migration:
```elixir
{Oban.Met, reporter: [auto_migrate: false]}
```
### Sketch Time Unit
Timing metrics (execution time, queue time) are stored in space-efficient quantile sketches. By
default, values are recorded in `:native` time units (nanoseconds on most systems), which provides
maximum precision but uses more space.
For reduced storage size (~20% smaller) and better bin consolidation, you can configure sketches to
store timing in milliseconds:
```elixir
config :oban_met, sketch_time_unit: :millisecond
```
This is a compile-time option that affects how timing values are binned in sketches.
> #### Cluster Consistency {: .warning}
>
> All nodes in a cluster **must** use the same `sketch_time_unit` setting. Mixing units between
> nodes will produce incorrect aggregated metrics.
>
> After changing this setting, existing metrics will be invalid until compaction ages them out
> (approximately 2 hours with default compaction periods).
<!-- MDOC -->
## Contributing
To run the test suite you must have PostgreSQL 12+. Once dependencies are installed, setup the
databases and run necessary migrations:
```bash
mix test.setup
```
## Community
There are a few places to connect and communicate with other Oban users:
- Ask questions and discuss *#oban* on the [Elixir Forum][forum]
- [Request an invitation][invite] and join the *#oban* channel on Slack
- Learn about bug reports and upcoming features in the [issue tracker][issues]
[invite]: https://elixir-slack.community/
[forum]: https://elixirforum.com/
[issues]: https://github.com/sorentwo/oban/issues

28
vendor/oban_met/hex_metadata.config vendored Normal file
View file

@ -0,0 +1,28 @@
{<<"links">>,
[{<<"Website">>,<<"https://oban.pro">>},
{<<"Changelog">>,
<<"https://github.com/oban-bg/oban_met/blob/main/CHANGELOG.md">>},
{<<"GitHub">>,<<"https://github.com/oban-bg/oban_met">>}]}.
{<<"name">>,<<"oban_met">>}.
{<<"version">>,<<"1.1.0">>}.
{<<"description">>,
<<"A distributed, compacting, multidimensional, telemetry-powered time series datastore">>}.
{<<"elixir">>,<<"~> 1.15">>}.
{<<"files">>,
[<<"lib">>,<<"lib/oban">>,<<"lib/oban/met">>,<<"lib/oban/met/examiner.ex">>,
<<"lib/oban/met/reporter.ex">>,<<"lib/oban/met/recorder.ex">>,
<<"lib/oban/met/cronitor.ex">>,<<"lib/oban/met/values">>,
<<"lib/oban/met/values/gauge.ex">>,<<"lib/oban/met/values/sketch.ex">>,
<<"lib/oban/met/value.ex">>,<<"lib/oban/met/migration.ex">>,
<<"lib/oban/met/listener.ex">>,<<"lib/oban/met/application.ex">>,
<<"lib/met.ex">>,<<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>,
<<"CHANGELOG.md">>,<<"LICENSE.txt">>]}.
{<<"app">>,<<"oban_met">>}.
{<<"licenses">>,[<<"Apache-2.0">>]}.
{<<"requirements">>,
[[{<<"name">>,<<"oban">>},
{<<"app">>,<<"oban">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 2.21">>},
{<<"repository">>,<<"hexpm">>}]]}.
{<<"build_tools">>,[<<"mix">>]}.

325
vendor/oban_met/lib/met.ex vendored Normal file
View file

@ -0,0 +1,325 @@
defmodule Oban.Met do
@external_resource readme = Path.join([__DIR__, "../README.md"])
@moduledoc readme
|> File.read!()
|> String.split("<!-- MDOC -->")
|> Enum.fetch!(1)
use Supervisor
alias Oban.Met.{Cronitor, Examiner, Listener, Recorder, Reporter, Value}
alias Oban.Registry
@type counts :: %{optional(String.t()) => non_neg_integer()}
@type sub_counts :: %{optional(String.t()) => non_neg_integer() | counts()}
@type series :: atom() | String.t()
@type value :: Value.t()
@type label :: String.t()
@type ts :: integer()
@type filter_value :: label() | [label()]
@type series_detail :: %{series: series(), labels: [label()], value: module()}
@type operation :: :max | :sum | {:pct, float()}
@type latest_opts :: [
filters: keyword(filter_value()),
group: nil | label(),
lookback: pos_integer()
]
@type timeslice_opts :: [
by: pos_integer(),
filters: keyword(filter_value()),
group: nil | label(),
label: nil | label(),
lookback: pos_integer(),
operation: operation(),
since: pos_integer()
]
@doc false
@spec child_spec(Keyword.t()) :: Supervisor.child_spec()
def child_spec(opts) do
conf = Keyword.fetch!(opts, :conf)
name = Registry.via(conf.name, __MODULE__)
opts
|> super()
|> Map.put(:id, name)
end
@doc """
Start a Met supervisor for an Oban instance.
`Oban.Met` typically starts supervisors automatically when Oban instances initialize. However,
starting a supervisor manually can be used if `auto_start` is disabled.
## Options
These options are required; without them the supervisor won't start:
* `:conf` configuration for a running Oban instance, required
* `:name` an optional name for the supervisor, defaults to `Oban.Met`
## Example
Start a supervisor for the default Oban instance:
Oban.Met.start_link(conf: Oban.config())
Start a supervisor with a custom name:
Oban.Met.start_link(conf: Oban.config(), name: MyApp.MetSup)
"""
@spec start_link(Keyword.t()) :: Supervisor.on_start()
def start_link(opts) when is_list(opts) do
conf = Keyword.fetch!(opts, :conf)
name = Registry.via(conf.name, __MODULE__)
Supervisor.start_link(__MODULE__, opts, name: name)
end
@doc """
Retrieve stored producer checks.
This mimics the output of the legacy `Oban.Web.Plugins.Stats.all_gossip/1` function.
Checks are queried approximately every second and broadcast to all connected nodes, so each node
is a replica of checks from the entire cluster. Checks are stored for 30 seconds before being
purged.
## Output
Checks are the result of `Oban.check_queue/1`, and the exact contents depends on which
`Oban.Engine` is in use. A `Basic` engine check will look similar to this:
%{
uuid: "2dde4c0f-53b8-4f59-9a16-a9487454292d",
limit: 10,
node: "me@local",
paused: false,
queue: "default",
running: [100, 102],
started_at: ~D[2020-10-07 15:31:00],
updated_at: ~D[2020-10-07 15:31:00]
}
## Examples
Get all current checks:
Oban.Met.checks()
Get current checks for a non-standard Oban isntance:
Oban.Met.checks(MyOban)
"""
@spec checks(Oban.name()) :: [map()]
def checks(oban \\ Oban) do
oban
|> Registry.via(Examiner)
|> Examiner.all_checks()
end
@doc """
Get a normalized, unified crontab from all connected nodes.
## Examples
Get a merged crontab:
Oban.Met.crontab()
[
{"* * * * *", "Worker.A", []},
{"* * * * *", "Worker.B", [["args", %{"mode" => "foo"}]]}
]
Get the crontab for a non-standard Oban instance:
Oban.Met.crontab(MyOban)
"""
@spec crontab(Oban.name()) :: [{binary(), binary(), map()}]
def crontab(oban \\ Oban) do
oban
|> Registry.via(Cronitor)
|> Cronitor.merged_crontab()
end
@doc """
Get all stored, unique values for a particular label.
## Examples
Get all known queues:
Oban.Met.labels("queue")
~w(alpha gamma delta)
Get all known workers:
Oban.Met.labels("worker")
~w(MyApp.Worker MyApp.OtherWorker)
"""
@spec labels(Oban.name(), label(), keyword()) :: [label()]
def labels(oban \\ Oban, label, opts \\ []) do
oban
|> Registry.via(Recorder)
|> Recorder.labels(to_string(label), opts)
end
@doc """
Get all stored values for a series without any filtering.
"""
@spec lookup(Oban.name(), series()) :: [term()]
def lookup(oban \\ Oban, series) do
oban
|> Registry.via(Recorder)
|> Recorder.lookup(to_string(series))
end
@doc """
Get the latest values for a gauge series, optionally subdivided by a label.
Unlike queues and workers, states are static and constant, so they'll always show up in the
counts or subdivision maps.
## Gauge Series
Latest counts only apply to `Gauge` series. There are two gauges available (as reported by
`series/1`:
* `:exec_count` jobs executing at that moment, including `node`, `queue`, `state`, and
`worker` labels.
* `:full_count` jobs in the database, including `queue`, and `state` labels.
## Examples
Get the `:full_count` value without any grouping:
Oban.Met.latest(:full_count)
%{"all" => 99}
Group the `:full_count` value by state:
Oban.Met.latest(:full_count, group: "state")
%{"available" => 9, "completed" => 80, "executing" => 5, ...
Group results by queue:
Oban.Met.latest(:exec_count, group: "queue")
%{"alpha" => 9, "gamma" => 3}
Group results by node:
Oban.Met.latest(:exec_count, group: "node")
%{"worker.1" => 6, "worker.2" => 5}
Filter values by node:
Oban.Met.latest(:exec_count, filters: [node: "worker.1"])
%{"all" => 6}
Filter values by queue and state:
Oban.Met.latest(:exec_count, filters: [node: "worker.1", "worker.2"])
"""
@spec latest(Oban.name(), series()) :: counts() | sub_counts()
def latest(oban \\ Oban, series, opts \\ []) do
oban
|> Registry.via(Recorder)
|> Recorder.latest(to_string(series), opts)
end
@doc """
List all recorded series along with their labels and value type.
## Examples
Oban.Met.series()
[
%{series: "exec_time", labels: ["state", "queue", "worker"], value: Sketch},
%{series: "wait_time", labels: ["state", "queue", "worker"], value: Sketch},
%{series: "exec_count", labels: ["state", "queue", "worker"], value: Gauge},
%{series: "full_count", labels: ["state", "queue"], value: Gauge}
]
"""
@spec series(Oban.name()) :: [series_detail()]
def series(oban \\ Oban) do
oban
|> Registry.via(Recorder)
|> Recorder.series()
end
@doc """
Summarize a series of data with an aggregate over a configurable window of time.
## Examples
Retreive a 3 second timeslice of the `exec_time` sketch:
Oban.Met.timeslice(Oban, :exec_time, lookback: 3)
[
{2, 16771374649.128689, nil},
{1, 24040058779.3428, nil},
{0, 22191534459.516357, nil},
]
Group `exec_time` slices by the `queue` label:
Oban.Met.timeslice(Oban, :exec_time, group: "queue")
[
{1, 9970235387.031698, "analysis"},
{0, 11700429279.446463, "analysis"},
{1, 23097311376.231316, "default"},
{0, 23097311376.231316, "default"},
{1, 1520977874.3348415, "events"},
{0, 2558504265.2738624, "events"},
...
"""
@spec timeslice(Oban.name(), series(), timeslice_opts()) :: [{ts(), value(), label()}]
def timeslice(oban \\ Oban, series, opts \\ []) do
oban
|> Registry.via(Recorder)
|> Recorder.timeslice(to_string(series), opts)
end
# Callbacks
@impl Supervisor
def init(opts) do
conf = Keyword.fetch!(opts, :conf)
children = [
{Cronitor, with_opts(:cronitor, conf, opts)},
{Examiner, with_opts(:examiner, conf, opts)},
{Recorder, with_opts(:recorder, conf, opts)},
{Listener, with_opts(:listener, conf, opts)},
{Reporter, with_opts(:reporter, conf, opts)},
{Task, fn -> :telemetry.execute([:oban, :met, :init], %{}, %{pid: self(), conf: conf}) end}
]
Supervisor.init(children, strategy: :one_for_one)
end
defp with_opts(name, conf, opts) do
real_name =
name
|> to_string()
|> String.capitalize()
|> then(&Module.safe_concat([Oban, Met, &1]))
init_opts = Keyword.get(opts, name, [])
:oban_met
|> Application.get_env(name, [])
|> Keyword.merge(init_opts)
|> Keyword.put(:conf, conf)
|> Keyword.put(:name, Registry.via(conf.name, real_name))
end
end

View file

@ -0,0 +1,83 @@
defmodule Oban.Met.Application do
@moduledoc false
use Application
alias Oban.{Config, Registry}
require Logger
@main_sup Oban.Met.Supervisor
@task_sup Oban.Met.TaskSupervisor
@handler_id :oban_met_handler
@impl Application
def start(_type, _args) do
:telemetry.attach(@handler_id, [:oban, :supervisor, :init], &__MODULE__.init_metrics/4, [])
children = [
{Task.Supervisor, name: @task_sup},
{Task, &boot_metrics/0}
]
Supervisor.start_link(children, strategy: :one_for_one, name: @main_sup)
end
@impl Application
def prep_stop(state) do
:telemetry.detach(@handler_id)
state
end
@doc false
def init_metrics(_event, _measure, %{conf: conf}, _conf) do
start_metrics(conf)
end
@doc false
def boot_metrics do
guard = {:andalso, {:is_map, :"$1"}, {:==, {:map_get, :__struct__, :"$1"}, Config}}
[{{:_, :_, :"$1"}, [guard], [:"$1"]}]
|> Registry.select()
|> Enum.each(&start_metrics/1)
end
defp start_metrics(conf) do
opts = Application.get_all_env(:oban_met)
if opts[:auto_start] and conf.testing in opts[:auto_testing_modes] do
spec = Oban.Met.child_spec(conf: conf)
case Supervisor.start_child(@main_sup, spec) do
{:ok, _pid} ->
Task.Supervisor.start_child(@task_sup, fn -> watch_oban(spec, conf) end)
:ok
{:error, {:already_started, _pid}} ->
:ok
{:error, {error, _stack}} ->
Logger.error("Unable to start Oban.Met supervisor: #{inspect(error)}")
end
end
end
defp watch_oban(%{id: child_id}, conf) do
ref =
conf.name
|> Oban.whereis()
|> Process.monitor()
receive do
{:DOWN, ^ref, :process, _pid, _reason} ->
Process.demonitor(ref, [:flush])
with :ok <- Supervisor.terminate_child(@main_sup, child_id) do
Supervisor.delete_child(@main_sup, child_id)
end
end
end
end

134
vendor/oban_met/lib/oban/met/cronitor.ex vendored Normal file
View file

@ -0,0 +1,134 @@
defmodule Oban.Met.Cronitor do
@moduledoc false
use GenServer
alias __MODULE__, as: State
alias Oban.Notifier
require Logger
defstruct [
:conf,
:name,
:timer,
crontabs: %{},
interval: :timer.seconds(15),
ttl: :timer.seconds(60)
]
@spec child_spec(Keyword.t()) :: Supervisor.child_spec()
def child_spec(opts) do
name = Keyword.get(opts, :name, __MODULE__)
opts
|> super()
|> Map.put(:id, name)
end
@spec start_link(Keyword.t()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: opts[:name])
end
def merged_crontab(name) do
GenServer.call(name, :merged_crontab)
end
# Callbacks
@impl GenServer
def init(opts) do
Process.flag(:trap_exit, true)
state = struct!(State, opts)
Notifier.listen(state.conf.name, [:cronitor])
{:ok, state, {:continue, :start}}
end
@impl GenServer
def handle_continue(:start, %State{} = state) do
handle_info(:share, state)
end
@impl GenServer
def terminate(_reason, state) do
if is_reference(state.timer), do: Process.cancel_timer(state.timer)
:ok
end
@impl GenServer
def handle_call(:share, _from, %State{} = state) do
{:noreply, state} = handle_info(:share, state)
{:reply, :ok, state}
end
def handle_call(:merged_crontab, _from, %State{} = state) do
merged_crontab =
for {_key, {_ts, crontab}} <- state.crontabs, entry <- crontab, uniq: true, do: entry
{:reply, merged_crontab, state}
end
@impl GenServer
def handle_info(:share, %State{} = state) do
%{name: name, node: node, plugins: plugins} = state.conf
crontab =
plugins
|> Keyword.get(Oban.Plugins.Cron, [])
|> Keyword.get(:crontab, [])
|> Enum.map(fn
{expr, work} -> {expr, inspect(work), %{}}
{expr, work, opts} -> {expr, inspect(work), Map.new(opts)}
end)
payload = %{crontab: crontab, name: inspect(name), node: node}
Notifier.notify(state.conf, :cronitor, payload)
state =
state
|> purge_stale()
|> schedule_share()
{:noreply, state}
end
def handle_info({:notification, :cronitor, payload}, %State{} = state) do
%{"crontab" => crontab, "name" => name, "node" => node} = payload
ts = System.system_time(:millisecond)
crontab = Enum.map(crontab, &List.to_tuple/1)
crontabs = Map.put(state.crontabs, {node, name}, {ts, crontab})
{:noreply, %{state | crontabs: crontabs}}
end
def handle_info(message, state) do
Logger.warning(
message: "Received unexpected message: #{inspect(message)}",
source: :oban_met,
module: __MODULE__
)
{:noreply, state}
end
# Helpers
defp purge_stale(%State{} = state) do
expires = System.system_time(:millisecond) - state.ttl
crontabs = Map.reject(state.crontabs, fn {_key, {ts, _tab}} -> ts < expires end)
%{state | crontabs: crontabs}
end
defp schedule_share(%State{} = state) do
%{state | timer: Process.send_after(self(), :share, state.interval)}
end
end

180
vendor/oban_met/lib/oban/met/examiner.ex vendored Normal file
View file

@ -0,0 +1,180 @@
defmodule Oban.Met.Examiner do
@moduledoc false
# Examiner uses notifications to periodically exchange queue state information between all
# interested nodes.
# This module is more of a "producer queue checker", but that name stinks.
use GenServer
alias __MODULE__, as: State
alias Oban.Notifier
require Logger
@type name_or_table :: :ets.tab() | GenServer.name()
defstruct [
:conf,
:name,
:table,
:timer,
interval: 750,
ttl: :timer.seconds(30)
]
@spec child_spec(Keyword.t()) :: Supervisor.child_spec()
def child_spec(opts) do
name = Keyword.get(opts, :name, __MODULE__)
opts
|> super()
|> Map.put(:id, name)
end
@spec start_link(Keyword.t()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: opts[:name])
end
@spec all_checks(name_or_table()) :: [map()]
def all_checks(name_or_table) do
name_or_table
|> table()
|> :ets.select([{{:_, :_, :"$1"}, [], [:"$1"]}])
end
@spec store(name_or_table(), map(), timestamp: integer()) :: :ok
def store(name_or_table, check, opts \\ []) when is_map(check) do
%{"node" => node, "name" => name, "queue" => queue} = check
check = normalize_limit(check)
timestamp = Keyword.get(opts, :timestamp, System.system_time(:millisecond))
name_or_table
|> table()
|> :ets.insert({{node, name, queue}, timestamp, check})
:ok
end
@spec purge(name_or_table(), pos_integer()) :: {:ok, non_neg_integer()}
def purge(name_or_table, ttl) when is_integer(ttl) and ttl > 0 do
expires = System.system_time(:millisecond) - ttl
pattern = [{{:_, :"$1", :_}, [{:<, :"$1", expires}], [true]}]
deleted =
name_or_table
|> table()
|> :ets.select_delete(pattern)
{:ok, deleted}
end
# Callbacks
@impl GenServer
def init(opts) do
Process.flag(:trap_exit, true)
table = :ets.new(:checks, [:public, read_concurrency: true])
state = struct!(State, Keyword.put(opts, :table, table))
Notifier.listen(state.conf.name, [:gossip])
Registry.register(Oban.Registry, state.name, table)
{:ok, state, {:continue, :start}}
end
@impl GenServer
def handle_continue(:start, %State{} = state) do
handle_info(:check, state)
end
@impl GenServer
def terminate(_reason, state) do
if is_reference(state.timer), do: Process.cancel_timer(state.timer)
:ok
end
@impl GenServer
def handle_info(:check, %State{} = state) do
match = [{{{state.conf.name, {:producer, :_}}, :"$1", :_}, [], [:"$1"]}]
checks =
Oban.Registry
|> Registry.select(match)
|> Task.async_stream(&safe_check(&1, state), on_timeout: :kill_task, ordered: false)
|> Enum.reduce([], fn
{:ok, check}, acc when is_map(check) -> [check | acc]
_, acc -> acc
end)
if Enum.any?(checks), do: Notifier.notify(state.conf, :gossip, %{checks: checks})
purge(state.table, state.ttl)
{:noreply, schedule_check(state)}
end
def handle_info({:notification, :gossip, %{"checks" => checks}}, %State{} = state) do
Enum.each(checks, &store(state.table, &1))
{:noreply, state}
end
def handle_info({:notification, :gossip, _payload}, state) do
{:noreply, state}
end
def handle_info(message, state) do
Logger.warning(
message: "Received unexpected message: #{inspect(message)}",
source: :oban_met,
module: __MODULE__
)
{:noreply, state}
end
# Table
defp table(tab) when is_reference(tab), do: tab
defp table(name) do
[{_pid, table}] = Registry.lookup(Oban.Registry, name)
table
end
# Scheduling
defp schedule_check(%State{} = state) do
%{state | timer: Process.send_after(self(), :check, state.interval)}
end
# Checking
defp safe_check(pid, state) do
if Process.alive?(pid) do
pid
|> GenServer.call(:check, state.interval)
|> sanitize_name()
end
catch
:exit, _ -> :error
end
defp sanitize_name(%{name: name} = check) when is_binary(name), do: check
defp sanitize_name(%{name: name} = check), do: %{check | name: inspect(name)}
defp normalize_limit(check) do
case Map.pop(check, "limit") do
{nil, check} -> check
{lim, check} -> Map.put(check, "local_limit", lim)
end
end
end

157
vendor/oban_met/lib/oban/met/listener.ex vendored Normal file
View file

@ -0,0 +1,157 @@
defmodule Oban.Met.Listener do
@moduledoc false
# Track local telemetry events and periodically relay them to external recorders.
use GenServer
alias __MODULE__, as: State
alias Oban.Met.Values.{Gauge, Sketch}
alias Oban.Notifier
@time_unit Application.compile_env(:oban_met, :sketch_time_unit, :native)
defstruct [
:conf,
:name,
:table,
:timer,
interval: :timer.seconds(1)
]
@spec child_spec(Keyword.t()) :: Supervisor.child_spec()
def child_spec(opts) do
name = Keyword.get(opts, :name, __MODULE__)
%{super(opts) | id: name}
end
@spec start_link(Keyword.t()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: opts[:name])
end
@doc false
@spec report(GenServer.name()) :: :ok
def report(name) do
GenServer.call(name, :report)
end
# Callbacks
@impl GenServer
def init(opts) do
Process.flag(:trap_exit, true)
table = :ets.new(:reporter, [:duplicate_bag, :public, write_concurrency: true])
state = struct!(State, Keyword.put(opts, :table, table))
:telemetry.attach_many(
handler_id(state),
[[:oban, :job, :stop], [:oban, :job, :exception]],
&__MODULE__.handle_event/4,
{state.conf, table}
)
{:ok, schedule_report(state)}
end
@impl GenServer
def terminate(_reason, %State{timer: timer} = state) do
if is_reference(timer), do: Process.cancel_timer(timer)
:telemetry.detach(handler_id(state))
:ok
end
@impl GenServer
def handle_info(:report, %State{conf: conf, table: table} = state) do
since = System.monotonic_time()
match = {:_, :"$2", :_}
guard = [{:<, :"$2", since}]
objects = :ets.select(table, [{match, guard, [:"$_"]}])
_delete = :ets.select_delete(table, [{match, guard, [true]}])
payload = %{
metrics: objects_to_metrics(objects),
name: inspect(conf.name),
node: conf.node,
time: System.system_time(:second)
}
Notifier.notify(conf, :metrics, payload)
{:noreply, schedule_report(state)}
end
defp objects_to_metrics(objects) do
objects
|> Enum.group_by(&elem(&1, 0), &elem(&1, 2))
|> Enum.map(fn {{series, state, queue, worker}, values} ->
value = if series == :exec_count, do: Gauge.new(values), else: Sketch.new(values)
%{series: series, state: state, queue: queue, worker: worker, value: value}
end)
end
@impl GenServer
def handle_call(:report, _from, %State{} = state) do
handle_info(:report, state)
{:reply, :ok, state}
end
# Telemetry Events
defp handler_id(state) do
"oban-met-recorder-#{inspect(state.name)}"
end
@doc false
def handle_event([:oban, :job, _], measure, %{conf: conf} = meta, {conf, tab}) do
%{job: %{queue: queue, worker: worker}, state: state} = meta
%{duration: exec_time, queue_time: wait_time} = measure
time = System.monotonic_time()
trst = trans_state(state)
# Sketches don't support negative times (caused when the VM wakes from sleep), or zero values
# (infrequently caused by the same situation). Times are converted to the configured unit
# before clamping.
exec_time = exec_time |> convert_time() |> abs() |> max(1)
wait_time = wait_time |> convert_time() |> abs() |> max(1)
:ets.insert(tab, [
{{:exec_time, trst, queue, worker}, time, exec_time},
{{:wait_time, trst, queue, worker}, time, wait_time},
{{:exec_count, trst, queue, worker}, time, 1}
])
end
def handle_event(_event, _measure, _meta, _conf), do: :ok
if @time_unit == :native do
defp convert_time(value), do: value
else
defp convert_time(value), do: System.convert_time_unit(value, :native, @time_unit)
end
# Scheduling
defp schedule_report(state) do
timer = Process.send_after(self(), :report, state.interval)
%{state | timer: timer}
end
# 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(:discard), do: :discarded
defp trans_state(:exhausted), do: :discarded
defp trans_state(:failure), do: :retryable
defp trans_state(:snoozed), do: :scheduled
defp trans_state(:success), do: :completed
defp trans_state(state), do: state
end

View file

@ -0,0 +1,103 @@
defmodule Oban.Met.Migration do
@moduledoc """
Migrations that add estimate functionality.
> #### Migrations Are Not Required {: .tip}
>
> Met will create the necessary estimate function automatically when possible. This migration
> isn't necessary under normal circumstances, but is provided to avoid permission issues or
> allow full control over database changes.
>
> See the section on [Explicit Migrations](installation.html) for more information.
## Usage
To use migrations in your application you'll need to generate an `Ecto.Migration` that wraps
calls to `Oban.Met.Migration`:
```bash
mix ecto.gen.migration add_oban_met
```
Open the generated migration and delegate the `up/0` and `down/0` functions to
`Oban.Met.Migration`:
```elixir
defmodule MyApp.Repo.Migrations.AddObanMet do
use Ecto.Migration
def up, do: Oban.Met.Migration.up()
def down, do: Oban.Met.Migration.down()
end
```
This will run all of the necessary migrations for your database.
"""
use Ecto.Migration
@doc """
Run the `up` migration.
## Example
Run all migrations up to the current version:
Oban.Met.Migration.up()
Run migrations in an alternate prefix:
Oban.Met.Migration.up(prefix: "payments")
"""
def up(opts \\ []) when is_list(opts) do
opts
|> Keyword.get(:prefix, "public")
|> oban_count_estimate()
|> execute()
end
@doc """
Run the `down` migration.
## Example
Run all migrations up to the current version:
Oban.Met.Migration.down()
Run migrations in an alternate prefix:
Oban.Met.Migration.down(prefix: "payments")
"""
def down(opts \\ []) when is_list(opts) do
prefix = Keyword.get(opts, :prefix, "public")
execute "DROP FUNCTION IF EXISTS #{prefix}.oban_count_estimate(text, text)"
end
# An `EXPLAIN` can only be executed as the top level of a query, or through an SQL function's
# EXECUTE as we're doing here. A named function helps the performance because it is prepared,
# and we have to support distributed databases that don't allow DO/END functions.
@doc false
def oban_count_estimate(prefix) do
"""
CREATE OR REPLACE FUNCTION #{prefix}.oban_count_estimate(state text, queue text)
RETURNS integer AS $func$
DECLARE
plan jsonb;
BEGIN
EXECUTE 'EXPLAIN (FORMAT JSON)
SELECT id
FROM #{prefix}.oban_jobs
WHERE state = $1::#{prefix}.oban_job_state
AND queue = $2'
INTO plan
USING state, queue;
RETURN plan->0->'Plan'->'Plan Rows';
END;
$func$
LANGUAGE plpgsql
"""
end
end

393
vendor/oban_met/lib/oban/met/recorder.ex vendored Normal file
View file

@ -0,0 +1,393 @@
defmodule Oban.Met.Recorder do
@moduledoc false
# Aggregate metrics via pubsub for querying and compaction.
use GenServer
alias __MODULE__, as: State
alias Oban.Met.{Value, Values.Gauge, Values.Sketch}
alias Oban.{Notifier, Peer}
@type series :: atom() | String.t()
@type value :: Value.t()
@type label :: String.t()
@type labels :: %{optional(String.t()) => label()}
@type ts :: integer()
@type period :: {pos_integer(), pos_integer()}
@periods [{1, 300}, {5, 1_200}, {30, 3_600}, {60, 7_200}]
@default_latest_opts [filters: [], group: nil, lookback: 2]
@default_timeslice_opts [
by: 1,
filters: [],
group: nil,
lookback: 60,
operation: :sum
]
defstruct [
:compact_timer,
:conf,
:name,
:table,
compact_periods: @periods,
handoff: :awaiting
]
@spec child_spec(keyword()) :: Supervisor.child_spec()
def child_spec(opts) do
name = Keyword.get(opts, :name, __MODULE__)
%{super(opts) | id: name}
end
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: opts[:name])
end
@spec lookup(GenServer.name(), series()) :: [term()]
def lookup(name, series) do
match = {{to_string(series), :_, :_}, :_, :_, :_}
:ets.select_reverse(table(name), [{match, [], [:"$_"]}])
end
@spec labels(GenServer.name(), label(), keyword()) :: [label()]
def labels(name, label, opts \\ []) when is_binary(label) do
opts = Keyword.validate!(opts, [:lookback, :since])
stime = Keyword.get(opts, :since, System.system_time(:second))
lookback = Keyword.get(opts, :lookback, 120)
match = {{:_, :_, :"$2"}, :_, :"$1", :_}
guard = [{:andalso, {:is_map_key, label, :"$1"}, {:>=, :"$2", stime - lookback}}]
value = [{:map_get, label, :"$1"}]
name
|> table()
|> :ets.select([{match, guard, value}])
|> :lists.usort()
end
@spec latest(GenServer.name(), series(), keyword()) :: %{optional(String.t()) => value()}
def latest(name, series, opts \\ []) do
opts = Keyword.validate!(opts, @default_latest_opts)
group = Keyword.fetch!(opts, :group)
lookback = Keyword.fetch!(opts, :lookback)
filters = Keyword.fetch!(opts, :filters)
name
|> table()
|> select(series, lookback, filters)
|> Enum.dedup_by(fn {{_, _, _}, _, labels, _} -> labels end)
|> Enum.group_by(fn {{_, _, _}, _, labels, _} -> labels[group] || "all" end)
|> Map.new(fn {group, metrics} ->
total =
metrics
|> Enum.map(&elem(&1, 3))
|> Enum.reduce(&Value.merge/2)
|> Value.sum()
{group, total}
end)
end
@spec series(GenServer.name()) :: [map()]
def series(name) do
match = {{:"$1", :_, :_}, :_, :"$2", :"$3"}
name
|> table()
|> :ets.select([{match, [], [:"$$"]}])
|> Enum.group_by(&hd/1)
|> Enum.map(fn {series, [[_series, _labels, %vtype{}] | _] = metrics} ->
labels =
for [_series, labels, _value] <- metrics,
key <- Map.keys(labels),
uniq: true,
do: key
%{series: series, labels: labels, type: vtype}
end)
|> Enum.sort_by(& &1.series)
end
@spec timeslice(GenServer.name(), series(), keyword()) :: [{ts(), value(), label()}]
def timeslice(name, series, opts \\ []) do
opts = Keyword.validate!(opts, [:since] ++ @default_timeslice_opts)
by = Keyword.fetch!(opts, :by)
group = Keyword.fetch!(opts, :group)
lookback = Keyword.fetch!(opts, :lookback)
operation = Keyword.fetch!(opts, :operation)
since = Keyword.get(opts, :since, System.system_time(:second))
name
|> table()
|> select(series, lookback, opts[:filters])
|> Enum.reduce(%{}, &merge_group(&1, &2, group))
|> Enum.reduce(%{}, &merge_chunk(&1, &2, since, by))
|> Enum.sort_by(fn {{label, chunk}, _} -> {label, -chunk} end)
|> Enum.map(fn {{label, chunk}, value} ->
value =
case operation do
:sum -> Value.sum(value)
:max -> Value.quantile(value, 1.0)
{:pct, ntile} -> Value.quantile(value, ntile)
end
{chunk, value, label}
end)
end
defp merge_group({{_, _, ts}, _, labels, value}, acc, group) do
Map.update(acc, {labels[group], ts}, value, &Value.union(&1, value))
end
defp merge_chunk({{label, ts}, value}, acc, since, by) do
chunk = div(since - ts - 1, by)
Map.update(acc, {label, chunk}, value, &Value.merge(&1, value))
end
def compact(name, periods) when is_list(periods) do
GenServer.call(name, {:compact, periods})
end
def store(name, series, value, labels, opts \\ []) do
time = Keyword.get(opts, :time, System.system_time(:second))
GenServer.call(name, {:store, {series, value, labels, time}})
end
# Callbacks
@impl GenServer
def init(opts) do
table =
:ets.new(:metrics, [
:compressed,
:ordered_set,
:protected,
read_concurrency: true
])
state =
State
|> struct!(Keyword.put(opts, :table, table))
|> schedule_compact()
Registry.register(Oban.Registry, state.name, table)
{:ok, state, {:continue, :start}}
end
@impl GenServer
def handle_continue(:start, %State{conf: conf} = state) do
payload = %{
syn: true,
module: __MODULE__,
name: inspect(conf.name),
node: conf.node
}
Notifier.notify(conf.name, :handoff, payload)
Notifier.listen(conf.name, [:gossip, :handoff, :metrics])
{:noreply, state}
end
@impl GenServer
def handle_call({:compact, periods}, _from, %State{table: table} = state) do
inner_compact(table, periods)
{:reply, :ok, state}
end
def handle_call({:store, params}, _from, %State{table: table} = state) do
{series, value, labels, time} = params
inner_store(table, series, value, labels, time)
{:reply, :ok, state}
end
@impl GenServer
def handle_info({:notification, :handoff, %{"syn" => _}}, state) do
if Peer.leader?(state.conf) do
data =
state.table
|> :ets.tab2list()
|> :erlang.term_to_binary()
|> Base.encode64()
payload = %{
ack: true,
module: __MODULE__,
data: data,
node: state.conf.node,
name: inspect(state.conf.name)
}
Notifier.notify(state.conf, :handoff, payload)
end
{:noreply, state}
end
def handle_info({:notification, :handoff, %{"ack" => _, "data" => data}}, %State{} = state) do
if state.handoff == :awaiting and not Peer.leader?(state.conf) do
data
|> Base.decode64!()
|> :erlang.binary_to_term()
|> then(&:ets.insert(state.table, &1))
end
{:noreply, %{state | handoff: :complete}}
end
def handle_info({:notification, :metrics, %{"metrics" => _} = payload}, state) do
%{"metrics" => metrics, "node" => node, "time" => time} = payload
for %{"series" => series, "value" => value} = metric <- metrics do
labels =
metric
|> Map.drop(~w(series value))
|> Map.put("node", node)
inner_store(state.table, series, from_map(value), labels, time)
end
{:noreply, state}
end
def handle_info({:notification, _channel, _payload}, state) do
{:noreply, state}
end
def handle_info(:compact, %State{compact_periods: periods, table: table} = state) do
inner_compact(table, periods)
:erlang.garbage_collect()
{:noreply, schedule_compact(state), :hibernate}
end
defp from_map(%{"size" => _} = value), do: Sketch.from_map(value)
defp from_map(value), do: Gauge.from_map(value)
# Table
defp table(name) do
case Registry.lookup(Oban.Registry, name) do
[{_pid, table}] ->
table
_ ->
raise RuntimeError, "no table registered for #{inspect(name)}"
end
end
defp inner_compact(table, periods) do
delete_outdated(table, periods)
Enum.reduce(periods, System.system_time(:second), fn {step, duration}, ts ->
since = ts - duration
match = {{:_, :_, :"$1"}, :"$2", :_, :_}
guard = [{:andalso, {:>=, :"$2", since}, {:"=<", :"$1", ts}}]
objects = :ets.select(table, [{match, guard, [:"$_"]}])
_delete = :ets.select_delete(table, [{match, guard, [true]}])
objects
|> Enum.chunk_by(fn {{ser, lab, max}, _, _, _} -> {ser, lab, div(ts - max - 1, step)} end)
|> Enum.map(&compact_object/1)
|> then(&:ets.insert(table, &1))
since
end)
end
defp compact_object([{{series, lab_key, _}, _, labels, _} | _] = metrics) do
{min_ts, max_ts} =
metrics
|> Enum.flat_map(fn {{_, _, max_ts}, min_ts, _, _} -> [max_ts, min_ts] end)
|> Enum.min_max()
value =
metrics
|> Enum.map(&elem(&1, 3))
|> Enum.reduce(&Value.merge/2)
{{series, lab_key, max_ts}, min_ts, labels, value}
end
defp delete_outdated(table, periods) do
systime = System.system_time(:second)
maximum = Enum.reduce(periods, 0, fn {_, duration}, acc -> duration + acc end)
since = systime - maximum
match = {{:_, :_, :"$1"}, :_, :_, :_}
guard = [{:<, :"$1", since}]
:ets.select_delete(table, [{match, guard, [true]}])
end
defp inner_store(table, series, value, labels, time) do
key = {to_string(series), :erlang.phash2(labels), time}
value =
case :ets.lookup(table, key) do
[{_key, _time, _labels, old_value}] -> Value.union(old_value, value)
_ -> value
end
:ets.insert(table, {key, time, labels, value})
end
# Scheduling
defp schedule_compact(state) do
time = Time.utc_now()
interval =
time
|> Time.add(60)
|> Map.put(:second, 0)
|> Time.diff(time)
|> Integer.mod(86_400)
|> System.convert_time_unit(:second, :millisecond)
timer = Process.send_after(self(), :compact, interval)
%{state | compact_timer: timer}
end
# Fetching & Filtering
defp select(table, series, since, filters) do
stime = System.system_time(:second)
match = {{to_string(series), :_, :"$2"}, :_, :"$1", :_}
guard = filters_to_guards(filters, {:>=, :"$2", stime - since})
:ets.select_reverse(table, [{match, [guard], [:"$_"]}])
end
defp filters_to_guards(nil, base), do: base
defp filters_to_guards(filters, base) do
Enum.reduce(filters, base, fn {field, values}, and_acc ->
and_guard =
values
|> List.wrap()
|> Enum.map(fn value -> {:==, {:map_get, to_string(field), :"$1"}, value} end)
|> Enum.reduce(fn or_guard, or_acc -> {:orelse, or_guard, or_acc} end)
{:andalso, and_guard, and_acc}
end)
end
end

216
vendor/oban_met/lib/oban/met/reporter.ex vendored Normal file
View file

@ -0,0 +1,216 @@
defmodule Oban.Met.Reporter do
@moduledoc false
# Periodically count and report jobs by state and queue.
#
# Because exact counts are expensive, counts for states with jobs beyond a configurable
# threshold are estimated. This is a tradeoff that aims to preserve system resources at the
# expense of accuracy.
use GenServer
import Ecto.Query, only: [from: 2, group_by: 3, select: 3, where: 3]
alias __MODULE__, as: State
alias Oban.{Job, Notifier, Peer, Repo}
alias Oban.Met.Migration
alias Oban.Met.Values.Gauge
alias Oban.Pro.Engines.Smart
require Logger
@empty_states %{
"available" => [],
"cancelled" => [],
"completed" => [],
"discarded" => [],
"executing" => [],
"retryable" => [],
"scheduled" => [],
"suspended" => []
}
defstruct [
:conf,
:name,
:queue_timer,
:check_timer,
auto_migrate: true,
checks: @empty_states,
check_counter: 0,
check_interval: :timer.seconds(1),
estimate_limit: 50_000,
function_created?: false,
queues: []
]
@spec child_spec(Keyword.t()) :: Supervisor.child_spec()
def child_spec(opts) do
name = Keyword.get(opts, :name, __MODULE__)
%{super(opts) | id: name}
end
@spec start_link(Keyword.t()) :: GenServer.on_start()
def start_link(opts) do
conf = Keyword.fetch!(opts, :conf)
opts =
if conf.repo.__adapter__() == Ecto.Adapters.Postgres do
opts
else
opts
|> Keyword.put(:auto_migrate, false)
|> Keyword.put(:estimate_limit, :infinity)
end
state = struct!(State, opts)
GenServer.start_link(__MODULE__, state, name: opts[:name])
end
# Callbacks
@impl GenServer
def init(%State{} = state) do
Process.flag(:trap_exit, true)
# Used to ensure testing helpers to auto-allow this module for sandbox access.
:telemetry.execute([:oban, :plugin, :init], %{}, %{conf: state.conf, plugin: __MODULE__})
{:ok, schedule_checks(state)}
end
@impl GenServer
def terminate(_reason, %State{} = state) do
if is_reference(state.check_timer), do: Process.cancel_timer(state.check_timer)
:ok
end
@impl GenServer
def handle_info(:checkpoint, %State{conf: conf} = state) do
if Peer.leader?(conf.name) do
state =
state
|> create_estimate_function()
|> cache_queues()
{:ok, checks} = checks(state)
metrics =
for {_key, counts} <- checks,
count <- counts,
do: Map.update!(count, :value, &Gauge.new/1)
payload = %{
metrics: metrics,
name: inspect(conf.name),
node: conf.node,
time: System.system_time(:second)
}
Notifier.notify(conf, :metrics, payload)
{:noreply,
schedule_checks(%{state | check_counter: state.check_counter + 1, checks: checks})}
else
{:noreply, schedule_checks(state)}
end
end
def handle_info(message, state) do
Logger.warning(
message: "Received unexpected message: #{inspect(message)}",
source: :oban_met,
module: __MODULE__
)
{:noreply, state}
end
# Scheduling
defp schedule_checks(state) do
timer = Process.send_after(self(), :checkpoint, state.check_interval)
%{state | check_timer: timer}
end
# Checking
defp create_estimate_function(%{auto_migrate: true, function_created?: false} = state) do
%{conf: %{prefix: prefix}} = state
query = Migration.oban_count_estimate(prefix)
Repo.query!(state.conf, query, [])
%{state | function_created?: true}
end
defp create_estimate_function(state), do: state
defp cache_queues(state) do
if Integer.mod(state.check_counter, 60) == 0 do
source = if state.conf.engine == Smart, do: "oban_producers", else: "oban_jobs"
query = from(p in source, select: p.queue, distinct: true)
%{state | queues: Repo.all(state.conf, query)}
else
state
end
end
defp checks(%{estimate_limit: limit} = state) do
{count_states, guess_states} =
for {state, counts} <- state.checks, reduce: {[], []} do
{count_acc, guess_acc} ->
total = Enum.reduce(counts, 0, &(&2 + &1.value))
if total < limit do
{[state | count_acc], guess_acc}
else
{count_acc, [state | guess_acc]}
end
end
count_query = count_query(count_states)
guess_query = guess_query(guess_states, state.queues, state.conf)
Repo.transaction(state.conf, fn ->
count_counts = Repo.all(state.conf, count_query)
guess_counts = Repo.all(state.conf, guess_query)
(count_counts ++ guess_counts)
|> Enum.group_by(& &1.state)
|> Enum.reduce(@empty_states, fn {state, counts}, acc ->
Map.put(acc, state, counts)
end)
end)
end
defp count_query([]), do: where(Job, [_], false)
defp count_query(states) when is_list(states) do
Job
|> select([j], %{series: :full_count, state: j.state, queue: j.queue, value: count(j.id)})
|> where([j], j.state in ^states)
|> group_by([j], [j.state, j.queue])
end
defp guess_query([], _queues, _conf), do: where(Job, [_], false)
defp guess_query(_states, [], _conf), do: where(Job, [_], false)
defp guess_query(states, queues, conf) when is_list(states) and is_list(queues) do
from(p in fragment("json_array_elements_text(?)", ^queues),
cross_join: x in fragment("json_array_elements_text(?)", ^states),
select: %{
series: :full_count,
state: x.value,
queue: p.value,
value: fragment("?.oban_count_estimate(?, ?)", literal(^conf.prefix), x.value, p.value)
}
)
end
end

44
vendor/oban_met/lib/oban/met/value.ex vendored Normal file
View file

@ -0,0 +1,44 @@
defprotocol Oban.Met.Value do
@moduledoc """
Tracked data access functions.
"""
@doc """
Add or append a new value to the data type.
"""
def add(struct, value)
@doc """
Merge two values into one.
"""
def merge(struct_1, struct_2)
@doc """
Compute the quantile for a value.
"""
def quantile(struct, quantile)
@doc """
Sum all data points for a value.
"""
def sum(struct)
@doc """
Union two values by reducing them into one.
"""
def union(struct_1, struct_2)
end
for module <- [Oban.Met.Values.Gauge, Oban.Met.Values.Sketch] do
defimpl Oban.Met.Value, for: module do
defdelegate add(struct, value), to: @for
defdelegate merge(struct_1, struct_2), to: @for
defdelegate quantile(struct, quantile), to: @for
defdelegate sum(struct), to: @for
defdelegate union(struct_1, struct_2), to: @for
end
end

View file

@ -0,0 +1,141 @@
defmodule Oban.Met.Values.Gauge do
@moduledoc """
One or more non-negative integers captured over time.
"""
alias __MODULE__, as: Gauge
@encoder if Code.ensure_loaded?(JSON.Encoder), do: JSON.Encoder, else: Jason.Encoder
@derive @encoder
defstruct data: 0
@type t :: %__MODULE__{data: [non_neg_integer()]}
@doc """
Initialize a new gauge.
## Examples
iex> Gauge.new(1).data
[1]
iex> Gauge.new([1, 2]).data
[1, 2]
"""
@spec new(non_neg_integer() | [non_neg_integer()]) :: t()
def new(data) when is_integer(data) and data > 0 do
%Gauge{data: [data]}
end
def new(data) when is_list(data) do
%Gauge{data: data}
end
@doc """
Add to the gauge.
## Examples
iex> 1
...> |> Gauge.new()
...> |> Gauge.add(1)
...> |> Gauge.add(1)
...> |> Map.fetch!(:data)
[3]
"""
@spec add(t(), pos_integer()) :: t()
def add(%Gauge{data: [head | tail]}, value) when value > 0 do
%Gauge{data: [head + value | tail]}
end
@doc """
Merges two gauges into a one.
## Examples
Merging two gauge retains all values:
iex> gauge_1 = Gauge.new([1, 2])
...> gauge_2 = Gauge.new([2, 3])
...> Gauge.merge(gauge_1, gauge_2).data
[1, 2, 2, 3]
"""
@spec merge(t(), t()) :: t()
def merge(%Gauge{data: data_1}, %Gauge{data: data_2}) do
%Gauge{data: data_1 ++ data_2}
end
@doc """
Compute the quantile for a gauge.
## Examples
With single values:
iex> Gauge.quantile(Gauge.new([1, 2, 3]), 1.0)
3
iex> Gauge.quantile(Gauge.new([1, 2, 3]), 0.5)
2
"""
@spec quantile(t(), float()) :: non_neg_integer()
def quantile(%Gauge{data: data}, ntile) do
length = length(data) - 1
data
|> Enum.sort()
|> Enum.at(floor(length * ntile))
end
@doc """
Compute the sum for a gauge.
## Examples
iex> Gauge.sum(Gauge.new(3))
3
iex> Gauge.sum(Gauge.new([1, 2, 3]))
6
"""
@spec sum(t()) :: non_neg_integer()
def sum(%Gauge{data: data}), do: Enum.sum(data)
@doc """
Union two gauges into a single value.
## Examples
Merging two gauge results in a single value:
iex> gauge_1 = Gauge.new([1, 2])
...> gauge_2 = Gauge.new([2, 3])
...> Gauge.union(gauge_1, gauge_2).data
[8]
"""
@spec union(t(), t()) :: t()
def union(%Gauge{data: data_1}, %Gauge{data: data_2}) do
data =
(data_1 ++ data_2)
|> Enum.sum()
|> List.wrap()
%Gauge{data: data}
end
@doc """
Initialize a gauge struct from a stringified map, e.g. encoded JSON.
## Examples
iex> Gauge.new(2)
...> |> Oban.JSON.encode!()
...> |> Oban.JSON.decode!()
...> |> Gauge.from_map()
...> |> Map.fetch!(:data)
[2]
"""
@spec from_map(%{optional(String.t()) => term()}) :: t()
def from_map(%{"data" => data}), do: %Gauge{data: data}
end

View file

@ -0,0 +1,186 @@
defmodule Oban.Met.Values.Sketch do
@moduledoc """
A fast and fully mergeable quantile sketch with relative error guarantees.
Derived from [DogSketch](https://github.com/moosecodebv/dog_sketch), based on DDSketch. This
variant has a hard-coded error rate of 0.02 for the sake of simplicity.
"""
alias __MODULE__, as: Sketch
@type t :: %__MODULE__{
data: %{optional(pos_integer()) => pos_integer()},
size: non_neg_integer()
}
@encoder if Code.ensure_loaded?(JSON.Encoder), do: JSON.Encoder, else: Jason.Encoder
@derive @encoder
defstruct data: %{}, size: 0
@error 0.02
@gamma (1 + @error) / (1 - @error)
@inv_log_gamma 1.0 / :math.log(@gamma)
@doc """
Create a new sketch instance with an optional error rate.
## Examples
iex> sketch = Sketch.new()
...> Sketch.size(sketch)
0
iex> sketch = Sketch.new(1)
...> Sketch.size(sketch)
1
iex> sketch = Sketch.new([1, 2, 3])
...> Sketch.size(sketch)
3
"""
@spec new(pos_integer() | [pos_integer()]) :: t()
def new(values \\ []) do
values
|> List.wrap()
|> Enum.reduce(%Sketch{}, &add(&2, &1))
end
@doc """
Insert sample values into a sketch.
## Examples
iex> Sketch.new()
...> |> Sketch.add(1)
...> |> Sketch.add(2)
...> |> Sketch.add(3)
...> |> Sketch.size()
3
"""
@spec add(t(), pos_integer()) :: t()
def add(%Sketch{} = sketch, value) when is_integer(value) and value > 0 do
bin = ceil(:math.log(value) * @inv_log_gamma)
data = Map.update(sketch.data, bin, 1, &(&1 + 1))
%{sketch | data: data, size: sketch.size + 1}
end
@doc """
Merge two sketch instances.
## Examples
iex> sketch_1 = Sketch.new([1])
...>
...> Sketch.new([2])
...> |> Sketch.merge(sketch_1)
...> |> Sketch.size()
2
"""
@spec merge(t(), t()) :: t()
def merge(%Sketch{} = sketch_1, %Sketch{} = sketch_2) do
data = Map.merge(sketch_1.data, sketch_2.data, fn _, val_1, val_2 -> val_1 + val_2 end)
%{sketch_1 | data: data, size: sketch_1.size + sketch_2.size}
end
@doc """
Compute the quantile value for a sketch.
## Examples
Without any values:
iex> Sketch.quantile(Sketch.new(), 0.5)
nil
With recorded values:
iex> Sketch.new()
...> |> Sketch.add(1)
...> |> Sketch.add(2)
...> |> Sketch.add(3)
...> |> Sketch.quantile(0.5)
...> |> trunc()
2
"""
@spec quantile(t(), float()) :: nil | float()
def quantile(%Sketch{size: 0}, _ntile), do: nil
def quantile(sketch, quantile) when quantile >= 0 and quantile <= 1 do
size_ntile = sketch.size * quantile
index =
sketch.data
|> Enum.sort_by(&elem(&1, 0))
|> Enum.reduce_while(0, fn {key, val}, size ->
if size + val >= size_ntile do
{:halt, key}
else
{:cont, size + val}
end
end)
2 * :math.pow(@gamma, index) / (@gamma + 1)
end
@doc """
Compute the sum for a sketch. Hardcoded to 0.
## Examples
iex> Sketch.sum(Sketch.new([1, 2, 3, 3]))
0.0
"""
@spec sum(t()) :: float()
def sum(%Sketch{data: _data}), do: 0.0
@doc """
Union two sketches into a single value. This is an alias for `merge/2`.
"""
@spec union(t(), t()) :: t()
def union(sketch_1, sketch_2), do: merge(sketch_1, sketch_2)
@doc """
Convert a sketch into a list of bins and values.
## Examples
iex> Sketch.new()
...> |> Sketch.add(1)
...> |> Sketch.add(2)
...> |> Sketch.add(3)
...> |> Sketch.to_list()
...> |> length()
3
"""
@spec to_list(t()) :: [float()]
def to_list(%Sketch{data: data}) do
for {key, val} <- data, do: {2 * :math.pow(@gamma, key) / (@gamma + 1), val}
end
@doc false
@spec size(t()) :: non_neg_integer()
def size(%Sketch{size: size}), do: size
@doc """
Initialize a sketch struct from a stringified map, e.g. encoded JSON.
## Examples
iex> Sketch.new([1, 2])
...> |> Oban.JSON.encode!()
...> |> Oban.JSON.decode!()
...> |> Sketch.from_map()
...> |> Sketch.quantile(1.0)
...> |> floor()
2
"""
@spec from_map(%{optional(String.t()) => term()}) :: t()
def from_map(%{"data" => data, "size" => size}) do
data = Map.new(data, fn {key, val} -> {String.to_integer(key), val} end)
%Sketch{data: data, size: size}
end
end

131
vendor/oban_met/mix.exs vendored Normal file
View file

@ -0,0 +1,131 @@
defmodule Oban.Met.MixProject do
use Mix.Project
@source_url "https://github.com/oban-bg/oban_met"
@version "1.1.0"
def project do
[
app: :oban_met,
version: @version,
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
deps: deps(),
docs: docs(),
aliases: aliases(),
package: package(),
# Description
name: "Oban Met",
description:
"A distributed, compacting, multidimensional, telemetry-powered time series datastore",
# Dialyzer
dialyzer: [
plt_add_apps: [:ex_unit],
plt_core_path: "_build/#{Mix.env()}",
flags: [:error_handling, :underspecs]
]
]
end
def application do
[
extra_applications: [:logger],
mod: {Oban.Met.Application, []},
env: [auto_start: true, auto_testing_modes: [:disabled]]
]
end
def cli do
[
preferred_envs: [
"ecto.gen.migration": :test,
"test.ci": :test,
"test.reset": :test,
"test.setup": :test
]
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_env), do: ["lib"]
defp package do
[
maintainers: ["Parker Selbert"],
licenses: ["Apache-2.0"],
files: ~w(lib .formatter.exs mix.exs README* CHANGELOG* LICENSE*),
links: %{
Website: "https://oban.pro",
Changelog: "#{@source_url}/blob/main/CHANGELOG.md",
GitHub: @source_url
}
]
end
defp docs do
[
main: "Oban.Met",
source_ref: "v#{@version}",
source_url: @source_url,
formatters: ["html"],
api_reference: false,
extra_section: "GUIDES",
extras: extras(),
groups_for_modules: groups_for_modules(),
skip_undefined_reference_warnings_on: ["CHANGELOG.md"]
]
end
defp extras do
[
"CHANGELOG.md": [filename: "changelog", title: "Changelog"]
]
end
defp groups_for_modules do
[
Values: [
Oban.Met.Value,
Oban.Met.Values.Gauge,
Oban.Met.Values.Sketch
]
]
end
defp deps do
[
{:oban, "~> 2.21"},
{:ecto_sqlite3, "~> 0.18", only: [:test, :dev]},
{:postgrex, "~> 0.20", only: [:test, :dev]},
{:stream_data, "~> 1.1", only: [:test, :dev]},
{:benchee, "~> 1.3", only: [:test, :dev], runtime: false},
{:credo, "~> 1.7", only: [:test, :dev], runtime: false},
{:dialyxir, "~> 1.3", only: [:test, :dev], runtime: false},
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:makeup_diff, "~> 0.1", only: :dev, runtime: false}
]
end
defp aliases do
[
release: [
"cmd git tag v#{@version} -f",
"cmd git push",
"cmd git push --tags",
"hex.publish --yes"
],
"test.reset": ["ecto.drop --quiet", "test.setup"],
"test.setup": ["ecto.create --quiet", "ecto.migrate --quiet"],
"test.ci": [
"format --check-formatted",
"deps.unlock --check-unused",
"credo",
"test --raise",
"dialyzer"
]
]
end
end

14
vendor/oban_web/.formatter.exs vendored Normal file
View file

@ -0,0 +1,14 @@
# Used by "mix format"
[
import_deps: [
:ecto,
:ecto_sql,
:phoenix
],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs,heex}"],
export: [
locals_without_parens: [oban_dashboard: 1, oban_dashboard: 2]
],
locals_without_parens: [oban_dashboard: 1, oban_dashboard: 2],
plugins: [Phoenix.LiveView.HTMLFormatter]
]

BIN
vendor/oban_web/.hex vendored Normal file

Binary file not shown.

237
vendor/oban_web/CHANGELOG.md vendored Normal file
View file

@ -0,0 +1,237 @@
# Changelog for Oban Web v2.12
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.
> #### 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.
## 🔀 Workflows Page
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.
<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>
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.
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.
## ⏰ Crons Page
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.
<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>
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.
For Pro users, `DynamicCron` entires can be created, edited, paused, resumed, or deleted directly
from a form on the details page.
## 🔍 Job Details
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.
<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>
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.
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.
## ➡️ Queues Table and 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.
<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>
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
- [Dashboard] Include `priv/timezones.txt` in hex package
The timezones file wasn't included in the package files list, which broke compilation for
downloaded packages.
## v2.12.0 - 2026-03-25
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] Preserve refresh changes between page changes
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.
- [Dahboard] Add a 30s option for refreshing
It's a simple addition that makes watching the cron page a bit more sensible.
- [Dashboard] Serve dynamically loaded mask based svg icons
Rather than manually defining SVG icons inline, SVG files are tracked and dynamically loaded
from a compiled assets module.
- [Dashboard] Serve font as static asset instead of embedding
Extract font out of the `app.css` and serve it as a stand-alone asset for better caching.
- [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
- [Dahboard] Fix instance switching when resolver returns a list
Ensure the instance name and allowed instances are strings before comparison
- [Dashboard] Poll registry instead of blocking on telemetry
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] Cache sidebar counts to prevent flickering
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.
- [Dashboard] Automatically update theme when OS theme changes
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.
- [Standalone] Use the Postgres notifier for standalone instance
The PG notifier can't (easily) connect to an external cluster for notifications. Connection is
possible through the Postgres notifier.

201
vendor/oban_web/LICENSE.txt vendored Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 The Oban Team
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

172
vendor/oban_web/README.md vendored Normal file
View file

@ -0,0 +1,172 @@
# Oban Web
<p align="center">
<a href="https://hex.pm/packages/oban_web">
<img alt="Hex Version" src="https://img.shields.io/hexpm/v/oban_web.svg" />
</a>
<a href="https://hexdocs.pm/oban_web">
<img alt="Hex Docs" src="http://img.shields.io/badge/hex.pm-docs-green.svg?style=flat" />
</a>
<a href="https://github.com/oban-bg/oban_web/actions">
<img alt="CI Status" src="https://github.com/oban-bg/oban_web/actions/workflows/ci.yml/badge.svg" />
</a>
<a href="https://opensource.org/licenses/Apache-2.0">
<img alt="Apache 2 License" src="https://img.shields.io/hexpm/l/oban_web" />
</a>
</p>
<!-- MDOC -->
Oban Web is a view of [Oban's][oba] inner workings that you host directly within your application.
Powered by [Oban Metrics][met] and [Phoenix Live View][liv], it is distributed, lightweight, and
fully realtime.
[oba]: https://github.com/oban-bg/oban
[met]: https://github.com/oban-bg/oban_met
[liv]: https://github.com/phoenixframework/phoenix_live_view
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/oban-bg/oban_web/main/assets/oban-web-preview-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/oban-bg/oban_web/main/assets/oban-web-preview-light.png" />
<img src="https://raw.githubusercontent.com/oban-bg/oban_web/refs/heads/main/assets/oban-web-preview-light.png" />
</picture>
</p>
## Features
- **🐦‍🔥 Embedded LiveView** - Mount the dashboard directly in your application without any
external dependencies.
- **📊 Realtime Charts** - Powered by a custom, distributed time-series data store that's compacted
for hours of efficient storage and filterable by node, queue, state, and worker.
- **🛸 Live Updates** - Monitor background job activity across all queues and nodes in real
time, with customizable refresh rates and automatic pausing on blur.
- **🔍 Powerful Filtering** - Intelligently filter jobs by worker, queue, args, tags and more with
auto-completed suggestions.
- **🔬 Detailed Inspection** - View job details including when, where and how it was ran (or how
it failed to run).
- **🔄 Batch Actions** - Cancel, delete and retry selected jobs or all jobs matching the current
filters.
- **🎛️ Queue Controls** - Scale, pause, resume, and stop queues across all running nodes. Queues
running with [Oban Pro](https://oban.pro) can also edit global limits, rate limiting, and
partitioning.
- **♊ Multiple Dashboards** - Switch between all running Oban instance from a single mount point,
or restrict access to some dashboards with exclusion controls.
- **🔒 Access Control** - Allow admins to control queues and interract with jobs while restricting
other users to read-only use of the dashboard.
- **🎬 Action Logging** - Use telemetry events to instrument and report all of a user's dashboard
activity. A telemetry-powered logger is provided for easy reporting.
## Installation
See the [installation guide](https://hexdocs.pm/oban_web/installation.html) for details on
installing and configuring Oban Web for your application.
### Standalone Docker Image
A standalone Docker image is available for monitoring Oban without embedding the dashboard in your
application:
```bash
docker run -d \
-e DATABASE_URL="postgres://user:pass@host:5432/myapp" \
-p 4000:4000 \
ghcr.io/oban-bg/oban-dash
```
See the [standalone guide](https://hexdocs.pm/oban_web/standalone.html) for configuration options
and details.
<!-- MDOC -->
## Contributing
To run the Oban Web test suite you must have PostgreSQL 12+ and MySQL 8+ running. Once dependencies
are installed, setup the databases and run necessary migrations:
```bash
mix test.setup
```
### Development Server
For development, a single file server that generates a wide variety of fake jobs is built in:
```bash
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
skip Pro-dependent tests (tagged with `@tag :pro`) when Oban Pro is not available.
To run tests with Pro features, you'll need access to a valid [Oban Pro](https://oban.pro)
license. First, authorize with the Oban repository:
```bash
mix hex.repo add oban https://repo.oban.pro \
--fetch-public-key SHA256:4/OSKi0NRF91QVVXlGAhb/BIMLnK8NHcx/EWs+aIWPc \
--auth-key YOUR_AUTH_KEY
```
Then fetch dependencies and run the full test suite:
```bash
mix deps.get
mix test
```
To explicitly run tests without Oban Pro:
```bash
mix test --exclude pro
```
## Community
There are a few places to connect and communicate with other Oban users:
- Ask questions and discuss *#oban* on the [Elixir Forum][forum]
- [Request an invitation][invite] and join the *#oban* channel on Slack
- Learn about bug reports and upcoming features in the [issue tracker][issues]
[invite]: https://elixir-slack.community/
[forum]: https://elixirforum.com/
[issues]: https://github.com/oban-bg/oban_web/issues

179
vendor/oban_web/hex_metadata.config vendored Normal file
View file

@ -0,0 +1,179 @@
{<<"links">>,
[{<<"Website">>,<<"https://oban.pro">>},
{<<"Changelog">>,
<<"https://github.com/oban-bg/oban_web/blob/main/CHANGELOG.md">>},
{<<"GitHub">>,<<"https://github.com/oban-bg/oban_web">>}]}.
{<<"name">>,<<"oban_web">>}.
{<<"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/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">>,
<<"lib/oban/web/components/icons.ex">>,
<<"lib/oban/web/components/sidebar_components.ex">>,
<<"lib/oban/web/components/core.ex">>,
<<"lib/oban/web/components/layouts.ex">>,
<<"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/detail_component.ex">>,
<<"lib/oban/web/live/queues/detail_instance_component.ex">>,
<<"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/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/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">>]}.
{<<"requirements">>,
[[{<<"name">>,<<"jason">>},
{<<"app">>,<<"jason">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 1.2">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"phoenix">>},
{<<"app">>,<<"phoenix">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 1.7">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"phoenix_html">>},
{<<"app">>,<<"phoenix_html">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 3.3 or ~> 4.0">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"phoenix_pubsub">>},
{<<"app">>,<<"phoenix_pubsub">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 2.1">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"phoenix_live_view">>},
{<<"app">>,<<"phoenix_live_view">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 1.0">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"oban">>},
{<<"app">>,<<"oban">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 2.21">>},
{<<"repository">>,<<"hexpm">>}],
[{<<"name">>,<<"oban_met">>},
{<<"app">>,<<"oban_met">>},
{<<"optional">>,false},
{<<"requirement">>,<<"~> 1.1">>},
{<<"repository">>,<<"hexpm">>}]]}.
{<<"build_tools">>,[<<"mix">>]}.

View file

@ -0,0 +1,134 @@
defmodule Mix.Tasks.ObanWeb.Install.Docs do
@moduledoc false
def short_doc do
"Installs Oban Web into your Phoenix application"
end
def example do
"mix oban_web.install"
end
def long_doc do
"""
#{short_doc()}
This task configures your Phoenix application to use the Oban Web dashboard:
* Adds the required `Oban.Web.Router` import
* Sets up the dashboard route at "/oban" within the :dev_routes conditional
## Example
```bash
#{example()}
```
"""
end
end
if Code.ensure_loaded?(Igniter) do
defmodule Mix.Tasks.ObanWeb.Install do
@shortdoc "#{__MODULE__.Docs.short_doc()}"
@moduledoc __MODULE__.Docs.long_doc()
use Igniter.Mix.Task
@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
group: :oban,
installs: [{:oban, "~> 2.0"}],
example: __MODULE__.Docs.example()
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
case Igniter.Libs.Phoenix.select_router(igniter) do
{igniter, nil} ->
Igniter.add_warning(igniter, """
No Phoenix router found, Phoenix Liveview is needed for Oban Web
""")
{igniter, router} ->
update_router(igniter, router)
end
end
defp update_router(igniter, router) do
zipper = &do_update_router(igniter, &1)
case Igniter.Project.Module.find_and_update_module(igniter, router, zipper) do
{:ok, igniter} ->
igniter
{:error, igniter} ->
Igniter.add_warning(igniter, """
Something went wrong, please check the Oban Web install docs for manual setup instructions
""")
end
end
defp do_update_router(igniter, zipper) do
web_module = Igniter.Libs.Phoenix.web_module(igniter)
app_name = Igniter.Project.Application.app_name(igniter)
with {:ok, zipper} <- add_import(zipper, web_module) do
add_route(zipper, app_name)
end
end
defp add_import(zipper, web_module) do
with {:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, web_module) do
{:ok, Igniter.Code.Common.add_code(zipper, "\nimport Oban.Web.Router")}
end
end
defp add_route(zipper, app_name) do
matcher = &dev_routes?(&1, app_name)
with {:ok, zipper} <- Igniter.Code.Function.move_to_function_call(zipper, :if, 2, matcher),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
{:ok,
Igniter.Code.Common.add_code(zipper, """
scope "/" do
pipe_through :browser
oban_dashboard "/oban"
end
""")}
end
end
defp dev_routes?(zipper, app_name) do
case Igniter.Code.Function.move_to_nth_argument(zipper, 0) do
{:ok, zipper} ->
Igniter.Code.Function.function_call?(zipper, {Application, :compile_env}, 2) and
Igniter.Code.Function.argument_equals?(zipper, 0, app_name) and
Igniter.Code.Function.argument_equals?(zipper, 1, :dev_routes)
_ ->
false
end
end
end
else
defmodule Mix.Tasks.ObanWeb.Install do
@shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use"
@moduledoc __MODULE__.Docs.long_doc()
use Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'ObanWeb.Task.Install' requires igniter. Please install igniter and try again.
For more information, see: https://hexdocs.pm/igniter/readme.html#installation
""")
exit({:shutdown, 1})
end
end
end

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

@ -0,0 +1,53 @@
defmodule Oban.Web do
@moduledoc false
alias Oban.Web.Layouts
def html do
quote do
@moduledoc false
import Phoenix.Controller, only: [get_csrf_token: 0, view_module: 1, view_template: 1]
unquote(html_helpers())
end
end
def live_view do
quote do
@moduledoc false
use Phoenix.LiveView, layout: {Layouts, :live}
unquote(html_helpers())
end
end
def live_component do
quote do
@moduledoc false
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
defp html_helpers do
quote do
use Phoenix.Component
import Oban.Web.Helpers
import Phoenix.HTML
import Phoenix.LiveView.Helpers
alias Oban.Web.Components.{Core, Icons}
alias Phoenix.LiveView.JS
end
end
@doc false
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View file

@ -0,0 +1,12 @@
defmodule Oban.Web.Application do
@moduledoc false
use Application
@impl Application
def start(_type, _args) do
children = [Oban.Web.Cache]
Supervisor.start_link(children, strategy: :one_for_one, name: __MODULE__)
end
end

97
vendor/oban_web/lib/oban/web/assets.ex vendored Normal file
View file

@ -0,0 +1,97 @@
defmodule Oban.Web.Assets do
@moduledoc false
@behaviour Plug
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"])
Module.put_attribute(__MODULE__, :external_resource, path)
path
end
@external_resource js_path = Path.join(@static_path, "app.js")
@js """
#{for path <- phoenix_js_paths, do: path |> File.read!() |> String.replace("//# sourceMappingURL=", "// ")}
#{File.read!(js_path)}
"""
@impl Plug
def init(asset), do: asset
@impl Plug
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")
|> put_private(:plug_skip_csrf_protection, true)
|> send_resp(200, contents)
|> halt()
end
for {key, val} <- [css: @css, js: @js] do
md5 = Base.encode16(:crypto.hash(:md5, val), case: :lower)
def current_hash(unquote(key)), do: unquote(md5)
end
end

View file

@ -0,0 +1,30 @@
defmodule Oban.Web.Authentication do
@moduledoc false
import Phoenix.Component
import Phoenix.LiveView
alias Oban.Web.{Resolver, Telemetry}
def on_mount(:default, params, session, socket) do
%{"oban" => oban, "resolver" => resolver, "user" => user} = session
conf = if Oban.whereis(oban), do: Oban.config(oban), else: nil
socket = assign(socket, conf: conf, user: user)
Telemetry.action(:mount, socket, [params: params], fn ->
case Resolver.call_with_fallback(resolver, :resolve_access, [user]) do
{:forbidden, path} ->
socket =
socket
|> put_flash(:error, "Access forbidden")
|> redirect(to: path)
{:halt, socket}
_ ->
{:cont, socket}
end
end)
end
end

53
vendor/oban_web/lib/oban/web/cache.ex vendored Normal file
View file

@ -0,0 +1,53 @@
defmodule Oban.Web.Cache do
@moduledoc false
use GenServer
defstruct [:name, purge_interval: :timer.seconds(300)]
def start_link(opts) do
opts = Keyword.put_new(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: opts[:name])
end
def fetch(name \\ __MODULE__, key, fun) do
if cache_enabled?() do
case :ets.lookup(name, key) do
[{^key, val}] ->
val
[] ->
tap(fun.(), &:ets.insert(name, {key, &1}))
end
else
fun.()
end
end
@impl GenServer
def init(opts) do
state = struct!(__MODULE__, opts)
:ets.new(state.name, [:set, :public, :compressed, :named_table])
{:ok, schedule_purge(state)}
end
@impl GenServer
def handle_info(:purge, state) do
:ets.delete_all_objects(state.name)
{:noreply, schedule_purge(state)}
end
defp cache_enabled? do
Process.get(:cache_enabled, Application.get_env(:oban_web, :cache))
end
defp schedule_purge(state) do
Process.send_after(self(), :purge, state.purge_interval)
state
end
end

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

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

View file

@ -0,0 +1,424 @@
defmodule Oban.Web.Components.Core do
use Oban.Web, :html
attr :click, :string, required: true
attr :danger, :boolean, default: false
attr :disabled, :boolean, default: false
attr :label, :string, required: true
attr :target, :any
slot :icon
slot :title
def action_button(assigns) do
class =
cond do
assigns.disabled ->
"text-gray-400"
assigns.danger ->
"text-red-500 group-hover:text-red-600 hover:bg-gray-100 dark:hover:bg-gray-950"
true ->
"text-gray-500 group-hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-950"
end
assigns = assign(assigns, :class, class)
~H"""
<button
class={["flex items-center space-x-2 px-3 py-1.5 rounded-md text-sm", @class]}
data-title={render_slot(@title)}
disabled={@disabled}
id={@click}
phx-click={@click}
phx-hook="Tippy"
phx-target={@target}
type="button"
>
{render_slot(@icon)}
<span class="block">{@label}</span>
</button>
"""
end
slot :inner_block, required: true
attr :name, :string, required: true
attr :options, :list, required: true
attr :selected, :any, required: true
attr :title, :string, required: true
attr :disabled, :boolean, default: false
attr :target, :any, default: "myself"
def dropdown_button(assigns) do
~H"""
<div class="relative" id={"#{@name}-selector"}>
<button
aria-expanded="true"
aria-haspopup="listbox"
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"}
phx-hook="Tippy"
phx-click={JS.toggle(to: "##{@name}-menu")}
type="button"
>
{render_slot(@inner_block)}
</button>
<ul
class="hidden absolute z-50 top-full right-0 mt-2 w-32 overflow-hidden border border-gray-100 dark:border-gray-800 rounded-md shadow-md text-sm font-semibold bg-white dark:bg-gray-800 focus:outline-none"
id={"#{@name}-menu"}
role="listbox"
tabindex="-1"
>
<%= for option <- @options do %>
<.dropdown_option name={@name} value={option} selected={@selected} target={@target} />
<% end %>
</ul>
</div>
"""
end
attr :name, :any, required: true
attr :value, :any, required: true
attr :selected, :any, required: true
attr :target, :any
defp dropdown_option(assigns) do
class =
if assigns.selected == assigns.value do
"text-blue-500 dark:text-blue-400"
else
"text-gray-500 dark:text-gray-400 "
end
assigns = assign(assigns, :class, class)
~H"""
<li
class={"block w-full py-1 px-2 flex items-center cursor-pointer select-none space-x-2 hover:bg-gray-50 hover:dark:bg-gray-600/30 #{@class}"}
id={"select-#{@name}-#{@value}"}
role="option"
phx-click={"select-#{@name}"}
phx-click-away={JS.hide(to: "##{@name}-menu")}
phx-target={@target}
phx-value-choice={@value}
>
<%= if to_string(@value) == to_string(@selected) do %>
<Icons.check class="w-5 h-5" />
<% else %>
<span class="block w-5 h-5"></span>
<% end %>
<span class="capitalize text-gray-800 dark:text-gray-200">
{@value |> to_string() |> String.replace("_", " ")}
</span>
</li>
"""
end
attr :checked, :boolean, default: false
attr :click, :string, required: true
attr :myself, :any, required: true
attr :value, :string, required: true
def row_checkbox(assigns) do
style =
if assigns.checked do
"border-blue-500 bg-blue-500"
else
"border-gray-400 dark:border-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-600"
end
assigns = assign(assigns, :style, style)
~H"""
<button
class="p-6 group"
phx-click={@click}
phx-target={@myself}
phx-value-id={@value}
rel="check"
>
<div class={["w-4 h-4 flex items-center justify-center rounded border", @style]}>
<Icons.check class="w-3 h-3 text-white dark:text-gray-900" />
</div>
</button>
"""
end
attr :checked, :atom, required: true
attr :click, :string, required: true
attr :myself, :any, required: true
def all_checkbox(assigns) do
style =
if assigns.checked in [:all, :some] do
"border-blue-500 bg-blue-500"
else
"border-gray-400 dark:border-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-600"
end
assigns = assign(assigns, :style, style)
~H"""
<button
class="p-6 group"
data-title="Select All"
id="toggle-select"
phx-click={@click}
phx-hook="Tippy"
phx-target={@myself}
type="button"
>
<div class={["w-4 h-4 flex items-center justify-center rounded border", @style]}>
<%= if @checked == :some do %>
<Icons.indeterminate class="w-3 h-3 text-white dark:text-gray-900" />
<% else %>
<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

View file

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

View file

@ -0,0 +1,162 @@
defmodule Oban.Web.Components.Icons do
@moduledoc false
use Phoenix.Component
@icons_path Application.app_dir(:oban_web, ["priv", "static", "icons"])
@outline_path Path.join(@icons_path, "outline")
@solid_path Path.join(@icons_path, "solid")
@outline_icons @outline_path
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".svg"))
|> Enum.map(&String.trim_trailing(&1, ".svg"))
@solid_icons @solid_path
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".svg"))
|> Enum.map(&String.trim_trailing(&1, ".svg"))
for icon <- @outline_icons do
@external_resource Path.join(@outline_path, "#{icon}.svg")
func_name = icon |> String.replace("-", "_") |> String.to_atom()
@doc false
attr :class, :any, default: "w-6 h-6"
attr :rest, :global
def unquote(func_name)(assigns) do
assigns = assign(assigns, :icon_path, "outline/#{unquote(icon)}.svg")
render_icon(assigns)
end
end
for icon <- @solid_icons do
@external_resource Path.join(@solid_path, "#{icon}.svg")
func_name =
icon
|> String.replace("-", "_")
|> Kernel.<>("_solid")
|> String.to_atom()
@doc false
attr :class, :any, default: "w-6 h-6"
attr :rest, :global
def unquote(func_name)(assigns) do
assigns = assign(assigns, :icon_path, "solid/#{unquote(icon)}.svg")
render_icon(assigns)
end
end
# Inline Icons
attr :rest, :global, default: %{class: "w-6 h-6"}
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">
<path d="m463.6 169.99-26.62-95.01-95-26.62 35.61 86z" fill="#e3f3fb" /><path
d="M427.97 256 377.6 134.36l86 35.62z"
fill="#99c5d4"
/><path d="m437 74.96 26.61 95 28.87-11.95a256.25 256.25 0 0 0-55.47-83z" fill="#4e7eac" /><path
d="M492.48 158.01a256.25 256.25 0 0 1 19.5 97.96l-48.37-86z"
fill="#16495e"
/><path d="m463.61 341.97 48.36-86-48.36-86-35.62 86z" fill="#ace6ea" /><path
d="M377.6 377.6 428 255.97l35.62 86z"
fill="#b6d6e8"
/><path d="m512 255.97-48.36 86 28.86 11.96A256.25 256.25 0 0 0 511.97 256z" fill="#406484" /><path
d="M492.5 353.93a256.25 256.25 0 0 1-55.48 83.05l26.62-95z"
fill="#5b84a6"
/><path d="m342.01 463.6 95.01-26.62 26.62-95-86 35.61z" fill="#9bc4e4" /><path
d="m256 427.97 121.63-50.38-35.62 86z"
fill="#506882"
/><path d="m437.04 437-95 26.61 11.95 28.87a256.25 256.25 0 0 0 83-55.47z" fill="#3c6a91" /><path
d="M353.99 492.48a256.25 256.25 0 0 1-97.96 19.5l86-48.37z"
fill="#1d4268"
/><path d="m170.03 463.61 86 48.36 86-48.36-86-35.62z" fill="#4d87b9" /><path
d="M134.4 377.6 256.03 428l-86 35.62z"
fill="#6588a8"
/><path d="m256.03 512-86-48.36-11.96 28.86A256.24 256.24 0 0 0 256 511.97z" fill="#156a90" /><path
d="M158.07 492.5a256.25 256.25 0 0 1-83.05-55.48l95.01 26.62z"
fill="#316087"
/><path d="m48.4 342.01 26.62 95.01 95.01 26.62-35.62-86z" fill="#417bb1" /><path
d="m84.03 256 50.38 121.63-86-35.62z"
fill="#87bdd7"
/><path d="m75 437.04-26.61-95-28.87 11.95a256.25 256.25 0 0 0 55.47 83z" fill="#284963" /><path
d="M19.52 353.99a256.25 256.25 0 0 1-19.5-97.96l48.37 86z"
fill="#467baa"
/><path d="m48.39 170.03-48.36 86 48.36 86 35.62-86z" fill="#7fadd0" /><path
d="M134.4 134.4 84 256.03l-35.62-86z"
fill="#abcae7"
/><path d="m0 256.03 48.36-86-28.86-11.96A256.24 256.24 0 0 0 .03 256z" fill="#94c0ce" /><path
d="M19.5 158.07a256.24 256.24 0 0 1 55.48-83.05l-26.62 95.01z"
fill="#b3d6e2"
/><path d="M169.99 48.4 74.98 75.02l-26.62 95.01 86-35.62z" fill="#e7fafa" /><path
d="M256 84.03 134.36 134.4l35.62-86z"
fill="#aed4f0"
/><path d="m74.96 75 95-26.61-11.95-28.87a256.24 256.24 0 0 0-83 55.47z" fill="#c6dde5" /><path
d="M158.01 19.52A256.25 256.25 0 0 1 255.97.02l-86 48.37z"
fill="#a9d5e9"
/><path d="m341.97 48.39-86-48.36-86 48.36 86 35.62z" fill="#f8ffff" /><path
d="M377.6 134.4 255.97 84l86-35.62z"
fill="#c2e8f8"
/><path d="m255.97 0 86 48.36 11.96-28.86A256.25 256.25 0 0 0 256 .03z" fill="#bedee6" /><path
d="M353.93 19.5a256.25 256.25 0 0 1 83.05 55.48l-95.01-26.62z"
fill="#9fcff0"
/>
</g>
</svg>
"""
end
attr :rest, :global, default: %{class: "w-6 h-6"}
def spinner(assigns) do
~H"""
<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
defp render_icon(assigns) do
icon_url = icon_url(assigns.icon_path)
class = normalize_class(assigns.class)
assigns = assigns |> assign(:icon_url, icon_url) |> assign(:normalized_class, class)
~H"""
<span
class={["block bg-current", @normalized_class]}
style={"mask-image: url(#{@icon_url}); -webkit-mask-image: url(#{@icon_url}); mask-size: contain; mask-repeat: no-repeat; mask-position: center;"}
aria-hidden="true"
{@rest}
/>
"""
end
defp icon_url(path) do
case Process.get(:routing) do
{socket, prefix} ->
Phoenix.VerifiedRoutes.unverified_path(
socket,
socket.router,
"#{prefix}/icons/#{path}"
)
_ ->
"/icons/#{path}"
end
end
defp normalize_class(class) when is_binary(class), do: class
defp normalize_class(class) when is_list(class), do: class
defp normalize_class(nil), do: "w-6 h-6"
end

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,32 @@
<meta name="live-transport" content={@live_transport} />
<meta name="live-path" content={@live_path} />
<main class="p-4 min-h-screen flex flex-col">
<%= if Phoenix.Flash.get(@flash, :info) do %>
<.notify flash={@flash} />
<% end %>
<header class="flex items-center">
<div class="md:w-84 mr-3">
<.logo path={@logo_path} />
</div>
<.nav socket={@socket} page={@page.name} />
<div class="ml-auto flex items-center space-x-3">
<.live_component module={Oban.Web.ShortcutsComponent} id="shortcuts" />
<.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"
conf={@conf}
resolver={@resolver}
user={@user}
/>
</div>
</header>
{@inner_content} <.footer />
</main>

View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content={get_csrf_token()} />
<title>{assigns[:page_title] || "Jobs • Oban"}</title>
<link
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACPTkDJAAAITElEQVRYCa1XfWwUxxWfmb297y/b5/sw4DPGh32AEzBuEnCJQWlUcIUTIWE1Kv9QC9FGapqqfxRERVwUCv0jJSEoFU0hivoR1UiWivlIpIQPV1AgLhTs82Eb+2xz9tm+8/m+9+52d6azG/YwwmmI6Uq7b+bNe7958+bNe7MQPOFD2ol6MJ5rJAQ3EwBrdQ7I2zaojImYCO52Z1ICT1hAwB2IUKdLZbm8sgXmnwQafpPQPw5ec2CA99aUrm6hwi6VDoZtjaxfY4MNhBBG0ocAigEfdyU4mPcCQkoBJKGRRKQdqtSHdu2qnPpfc3ytAb52n9o/EN9HAPglBTV47MsvLmqwQuNSVEcAMc8HikWYuHsjc/PeaJTEc5lNFDxN5d6x2moOtnyNR+Y1QFo1T3AHVa40FhkGHZU2JhGYSNk8Fl35dypsDIu88xmQiQv+ySEuIoiEy2qxaSyQFOKxnIcQMAxZ9bb5vPGYASFfcsXEveihIpelhk6+XJoICzh9/a8XRAokr7x0aUmv53s1MZPdUE8wAJGxXHf4PmcVBbxKkocQxt31VhVExCD1Y7HcwOQ4119s0u5ZWWfqk3jKI++h0un5/JpDYyw+WuwqaVbr1CUKP3g7cC0+OSsbI/EyMc4evHlfCA9MH8jkLZ8nIrmXMSbVijylWoDANZ1Z5ZZ4Wq2qxGbXVv+763q5S++5cL33grQ18oOUhs/nU2cB36HWGOYCycMTfWMOuQEBpqs7gxjU1Pz25qpNb3z32OqXit6vb7JVQcg00Wg8QwOS+gSARCj3lY6s+NWnqrayGnN8R9v2drXCVimNdGj21ypGW0kd6FR4Eo1PzPRgXnAiBA6zDDi+pW3ziDx+QP7KH2oUjVVwXnpvnZ+t4LG4m2DSyiWEHuqFWkWyrMK1HCBonhH69lHeWxJfjgHJ9VnCDxUXLb5psizeoChgEfSE70X/cH84cbLpDU9O4T8JHTxHNMQZ36mxMK/rLdqCEX88cOKfk2NTdUinWvbeJ/um5C1IY34vXYJBb7BRj8A0n4FdUz6mP/glcqKM/cS3nVwy0NMEc3DS8lGwN+Ic+td4f2Qs2UUDNv3s+mcZeowNCIO9khxs296mnoaxkfrnau9ueG4Hnxxnn6fRbJEHITxcv4ORBaX+Qp6rp0cPEYz3SLrU3XGgBtf1Zsy6PWU1Vru2AoWEcCPP510+fwgGh3P6wuQ0mBgGHV/IpHN1IIuPK4FJvWzhoV5HtHZIea54VGhEBMJmiGB4iXd9XSAVKBw9mk7PrXkNjswFW0h73ZalI3Tp5xRdntHYIlGxjsZXGIpiMwIE19oWe/0QseZkPuHFBPskYQSYDxSlp6V0uTIWYdle6gUpi5rvjvB+2q5FiNXwTvczDcokwUwwRlPZ8JofgU8V3tPSF7a6P5Uw84w+pmClU7ghnQE8WrL8BSMtr4WMGMqG1goi/+6Ds63IPxWVsAhijtCgqn8IRJjBQN6IjNZH8g7AGAOjMfVQ7v/UuvbZDKTJ6RG02zduA8RNJlJ0j0RlxKzRd5tYzZu01j9WqBSZb0slrHwm/2bIP9Ot6NLaIf7t+MkUwjmeFXP8FWVglW2RlVa9ytHOzGaF97T0d63dm6kRlRN90SIF62x7x5V0MsXSTEh6spNJL92nhAox/hKtQS6plP+6Ivy0FD/AEnm8MhXh/LlsNnHx7GdeWhfuIILgaSwIpUKSu7nMao8UJiOgKXCeqyj0F9g49JNbFXTnmxT1ke7pyMfvf3iTbkEpvWl1Ik9R9WU6GBr1DxFdGnM0aSQkYZqvEeLF3YriQinJ87vpRHLNYTVM3LlEzUWnwhScCXlLwWVVS9vK/LKSpnZBEH6ej2avNtSuZ8rdjq5Fi4scgAGt5Bxpg7SwLMSAoz8b1KTSs61Wu6a/Zm3JlMWmXjszEzdZrdZ19H3vVNeBvHz+rSbvLYKFn6a51ESdZ23V7GzSPTYatiUSmeG+yXHB++JLdy5dOlU4KU9izLmjgxpTBdq5al2pd8ly0wqNnnFTPfWt7p6RsZFxm5rVbO8NXEjLBsTS/WmrtkqbyqZf3rh6o1GZIMvlHVwm756dHtq/cc0Pijc9v3XoUvfZQjZT5ObSj47cqnj1+62/SonZPzvLzF6tni3cBSS5zo4vNLkcf6z94v4zUl/eG6lhXGR+mx6V4YlIaEDqKw+tCbU6lSFKY2IP4cWht358rPM3uz7YMjdPSO0TR77ccvKdG51Y5IdogO1hEIgazQ8vIhKeKIgDyXhqGNnhQQX/kWSz1N7saFyx/sONqxu3KgISzRGuazzZ9+JcHi2nw9S4d52Vawirg7+QzvnccYezqMtRZnlEZzw43fn3j0/v+uSLfYWflUcMkAB+u7N9hZaYDlvM5moGIfkmTHNEejT5H0HAgnxRoQfEx/CWWZS31AOaMFk9022tMFpZE/sgh8B47ZpyFdWTr+XxSGpgOhTv15eY9ry6w9M319DHDJAGX1t10EELVYdOq6usKCsbXOJyqXIkkUzkZ3RMttgGBM28PyaIgX6jSx8pcVs5tYYx3r83JSZiGfpjQoY1DNy270+vFFauGFGoggpDor3TF9LPNG78SzYkGMOx2W1DY6OVjMk6Vl6+kuRifDUtKpq58kqbVlXNdDQyMRAYROmo8GKW4210hceQ29O6//cb5PyiyCp0Xg8ogxKVvMEBYe+WH77SwjCsi97swumpmD85EW2gl0x5AVIxy+Ls1SAXrOExX0rVQkudVe06FTo036rn4n+jAYpwO/09Twf8jSImzTQAa7Eo8omRiDE1mwTjufupjJhh6aHqoVe501WG8sttp1qe6Pf8v6GkrKXydR7QAAAAAElFTkSuQmCC"
nonce={@csp_nonces.img}
rel="icon"
type="image/png"
/>
<link rel="stylesheet" nonce={@csp_nonces.style} href={asset_path(@conn, :css)} />
<script nonce={@csp_nonces.script}>
const wantsDark = window.matchMedia("(prefers-color-scheme: dark)").matches
const theme = JSON.parse(localStorage.getItem("oban:theme"));
if (theme === "dark" || (theme === "system" && wantsDark) || (!theme && wantsDark)) {
document.documentElement.classList.add("dark")
} else {
document.documentElement.classList.remove("dark")
}
// Apply sidebar width before page renders to prevent flash
const sidebarWidth = JSON.parse(localStorage.getItem("oban:sidebar_width")) || 320;
document.documentElement.style.setProperty("--sidebar-width", sidebarWidth + "px");
</script>
<script nonce={@csp_nonces.script} src={asset_path(@conn, :js)} defer>
</script>
</head>
<body class="min-h-screen antialiased bg-gray-200 dark:bg-gray-950 text-gray-900 dark:text-gray-100 transition duration-200 ease-out">
{@inner_content}
</body>
</html>

View file

@ -0,0 +1,123 @@
defmodule Oban.Web.SidebarComponents do
use Oban.Web, :html
attr :csp_nonces, :map
attr :width, :integer, default: 320
slot :inner_block
def sidebar(assigns) do
~H"""
<style nonce={@csp_nonces.style}>
#sidebar {
width: var(--sidebar-width, <%= @width %>px);
}
</style>
<div id="sidebar" class="relative flex-none mr-2 pr-3" phx-hook="SidebarResizer">
{render_slot(@inner_block)}
<div
data-resize-handle
class="absolute top-0 right-0 bottom-0 w-1 cursor-col-resize group hover:bg-violet-400 transition-colors"
>
<div class="absolute top-8 right-0 w-1 h-12 bg-gray-300 dark:bg-gray-600 group-hover:bg-violet-500 rounded-full transition-colors">
</div>
</div>
</div>
"""
end
attr :name, :string, required: true
attr :headers, :list, required: true
slot :inner_block
def section(assigns) do
~H"""
<div id={@name} class="bg-transparent dark:bg-transparent w-full mb-3 overflow-hidden">
<header class="flex justify-between items-center py-3">
<button
id={"#{@name}-toggle"}
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"
>
<Icons.chevron_right class="w-4 h-4 mt-1 mr-1 transition-transform rotate-90" />
</button>
<h3 class="dark:text-gray-200 font-bold">{String.capitalize(@name)}</h3>
<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-12">{header}</span>
</div>
</header>
<div id={"#{@name}-rows"}>{render_slot(@inner_block)}</div>
</div>
"""
end
attr :name, :string, required: true
attr :values, :list, required: true
attr :patch, :any, required: true
attr :active, :boolean, default: false
attr :exclusive, :boolean, default: false
slot :statuses
def filter_row(assigns) do
class =
cond do
assigns.exclusive and assigns.active ->
"rounded-md bg-white hover:bg-white dark:bg-gray-800 dark:hover:bg-gray-800"
assigns.exclusive ->
"rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
assigns.active ->
"border-violet-500 hover:border-violet-500 focus:border-violet-500"
true ->
"hover:border-violet-400 focus:border-violet-200"
end
assigns = assign(assigns, :class, class)
~H"""
<.link
class={["flex justify-between pr-2 py-2 ml-2 my-0.5 border-l-4 border-transparent", @class]}
id={"filter-#{@name}"}
patch={@patch}
replace={true}
>
<span class={[
"pl-2 text-sm text-gray-700 dark:text-gray-300 text-left tabular font-medium truncate",
if(@active, do: "font-semibold")
]}>
{String.downcase(@name)}
</span>
<div class="flex-none flex items-center space-x-2">
<div
:if={@statuses}
class="flex items-center text-right space-x-1 text-gray-600 dark:text-gray-400"
>
{render_slot(@statuses)}
</div>
<span
:for={value <- @values}
class="block w-10 text-sm text-right tabular text-gray-600 dark:text-gray-400"
>
{integer_to_estimate(value)}
</span>
</div>
</.link>
"""
end
defp toggle(prefix) do
%JS{}
|> JS.toggle(in: "fade-in-scale", out: "fade-out-scale", to: "##{prefix}-rows")
|> JS.add_class("rotate-90", to: "##{prefix}-toggle svg:not(.rotate-90)")
|> JS.remove_class("rotate-90", to: "##{prefix}-toggle svg.rotate-90")
end
end

View file

@ -0,0 +1,84 @@
defmodule Oban.Web.SortComponent do
use Oban.Web, :html
attr :id, :string, default: "job-sort"
attr :by, :list, required: true
attr :page, :atom, default: :jobs
attr :params, :map, required: true
def select(assigns) do
~H"""
<div id={@id} class="w-28 relative">
<button
aria-expanded="true"
aria-haspopup="listbox"
class="w-full flex justify-left items-center cursor-pointer rounded-md bg-white
dark:bg-gray-900 py-2.5 px-3 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-800
dark:hover:text-gray-200 ring-1 ring-inset ring-gray-300 dark:ring-gray-700 focus:outline-none focus:ring-blue-500"
data-title="Change sort order"
id="sort-menu-button"
phx-click={JS.toggle(to: "#sort-menu")}
phx-hook="Tippy"
type="button"
>
<%= if @params.sort_dir == "asc" do %>
<Icons.bars_arrow_down class="w-4 h-4" />
<% else %>
<Icons.bars_arrow_up class="w-4 h-4" />
<% end %>
<span class="ml-1 block capitalize">
{String.replace(@params.sort_by, "_", " ")}
</span>
</button>
<nav
class="hidden absolute z-10 mt-1 w-full text-sm font-semibold overflow-auto rounded-md bg-white
dark:bg-gray-800 shadow-lg ring-1 ring-black/5 focus:outline-none"
id="sort-menu"
role="listbox"
tabindex="-1"
>
<.option
:for={value <- @by}
link={oban_path(@page, Map.put(@params, :sort_by, value))}
selected={@params.sort_by}
value={value}
/>
<hr class="w-full border-0 border-b border-gray-200 dark:border-gray-700 my-2" />
<.option
:for={value <- ~w(asc desc)}
link={oban_path(@page, Map.put(@params, :sort_dir, value))}
selected={@params.sort_dir}
value={value}
/>
</nav>
</div>
"""
end
attr :link, :any, required: true
attr :selected, :string, required: true
attr :value, :string, required: true
defp option(assigns) do
~H"""
<.link
class="block w-full flex items-center py-1 px-2 cursor-pointer select-none space-x-2 hover:bg-gray-50 hover:dark:bg-gray-600/30"
id={"sort-#{@value}"}
patch={@link}
phx-click-away={JS.hide(to: "#sort-menu")}
phx-click={JS.hide(to: "#sort-menu")}
role="option"
>
<%= if @value == @selected do %>
<Icons.check class="w-4 h-4 text-blue-500" />
<% else %>
<span class="block w-4 h-4"></span>
<% end %>
<span class="capitalize text-gray-800 dark:text-gray-200">
{String.replace(@value, "_", " ")}
</span>
</.link>
"""
end
end

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

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

View file

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

View file

@ -0,0 +1,252 @@
defmodule Oban.Web.DashboardLive do
use Oban.Web, :live_view
alias Oban.Web.{CronsPage, JobsPage, QueuesPage, WorkflowsPage}
@impl Phoenix.LiveView
def mount(params, session, socket) do
%{"prefix" => prefix, "resolver" => resolver} = session
%{"live_path" => live_path, "live_transport" => live_transport} = session
%{"user" => user, "access" => access, "csp_nonces" => csp_nonces} = session
%{"refresh" => refresh, "logo_path" => logo_path} = session
refresh = restore_state(socket, "refresh", refresh)
theme = restore_state(socket, "theme", "system")
sidebar_width = restore_state(socket, "sidebar_width", 320)
oban = current_oban_instance(session, socket)
conf = await_init([oban])
_met = await_init([oban, Oban.Met])
page = resolve_page(params)
Process.put(:routing, {socket, prefix})
socket =
socket
|> assign(conf: conf, params: params, page: page, init_state: init_state(socket))
|> assign(live_path: live_path, live_transport: live_transport, logo_path: logo_path)
|> assign(access: access, csp_nonces: csp_nonces, resolver: resolver, user: user)
|> assign(original_refresh: nil, refresh: refresh, timer: nil, theme: theme)
|> assign(sidebar_width: sidebar_width)
|> init_schedule_refresh()
|> page.comp.handle_mount()
{:ok, socket}
end
defp current_oban_instance(session, socket) do
stashed = restore_state(socket, "instance")
default = List.first(oban_instances())
case stashed || session["oban"] || default || Oban do
name when is_binary(name) -> name |> String.split(".") |> Module.safe_concat()
name -> name
end
end
defp init_state(socket) do
case get_connect_params(socket) do
%{"init_state" => state} -> state
_ -> %{}
end
end
defp restore_state(socket, key, default \\ nil) do
socket
|> init_state()
|> Map.get("oban:" <> key, default)
end
@impl Phoenix.LiveView
def render(assigns) do
assigns =
assigns
|> Map.put(:id, "page")
|> Map.drop(~w(flash live_path live_transport refresh socket timer)a)
~H"""
<.live_component id="page" module={@page.comp} {assigns} />
"""
end
@impl Phoenix.LiveView
def terminate(_reason, %{assigns: %{timer: timer}}) do
if is_reference(timer), do: Process.cancel_timer(timer)
:ok
end
def terminate(_reason, _socket), do: :ok
@impl Phoenix.LiveView
def handle_params(params, uri, socket) do
page = resolve_page(params)
if page == socket.assigns.page do
page.comp.handle_params(params, uri, socket)
else
socket =
socket
|> assign(params: params, page: page)
|> page.comp.handle_mount()
page.comp.handle_params(params, uri, socket)
end
end
@impl Phoenix.LiveView
def handle_info(:clear_flash, socket) do
{:noreply, clear_flash(socket)}
end
def handle_info(:pause_refresh, socket) do
socket =
if socket.assigns.refresh > 0 do
cancel_timer(socket)
assign(socket, refresh: -1, original_refresh: socket.assigns.refresh, timer: nil)
else
socket
end
{:noreply, socket}
end
def handle_info(:resume_refresh, socket) do
if original = socket.assigns.original_refresh do
handle_info({:update_refresh, original}, socket)
else
{:noreply, socket}
end
end
def handle_info({:select_instance, name}, socket) do
name = name |> String.split(".") |> Module.safe_concat()
conf = Oban.config(name)
name = inspect(name)
page = socket.assigns.page
socket =
socket
|> assign(oban: name, conf: conf)
|> push_event("select-instance", %{name: name})
|> put_flash_with_clear(:info, "Switched instance to #{name}")
|> page.comp.handle_refresh()
{:noreply, socket}
end
def handle_info(:toggle_refresh, socket) do
%{original_refresh: original, refresh: refresh} = socket.assigns
cond do
refresh > 0 ->
socket =
socket
|> assign(refresh: -1, original_refresh: refresh)
|> push_event("update-refresh", %{refresh: -1})
{:noreply, socket}
is_integer(original) ->
handle_info({:update_refresh, original}, socket)
true ->
handle_info({:update_refresh, 1}, socket)
end
end
def handle_info({:update_refresh, refresh}, socket) do
socket =
socket
|> assign(refresh: refresh, original_refresh: nil)
|> schedule_refresh()
{:noreply, push_event(socket, "update-refresh", %{refresh: refresh})}
end
def handle_info({:update_theme, theme}, socket) do
{:noreply,
socket
|> assign(theme: theme)
|> push_event("update-theme", %{theme: theme})}
end
def handle_info(:refresh, socket) do
socket =
socket
|> socket.assigns.page.comp.handle_refresh()
|> schedule_refresh()
{:noreply, socket}
end
def handle_info(message, socket) do
socket.assigns.page.comp.handle_info(message, socket)
end
@impl Phoenix.LiveView
def handle_event("sidebar_resize", %{"width" => width}, socket) do
{:noreply, assign(socket, sidebar_width: width)}
end
def handle_event(event, params, socket) do
socket.assigns.page.comp.handle_event(event, params, socket)
end
## Mount Helpers
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 ->
raise RuntimeError, "no config registered for #{inspect(args)} instance"
pid when is_pid(pid) ->
Oban.config(oban_name)
end
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
defp init_schedule_refresh(socket) do
if connected?(socket) do
schedule_refresh(socket)
else
assign(socket, timer: nil)
end
end
defp schedule_refresh(socket) do
cancel_timer(socket)
if socket.assigns.refresh > 0 do
interval = :timer.seconds(socket.assigns.refresh) - 50
assign(socket, timer: Process.send_after(self(), :refresh, interval))
else
assign(socket, timer: nil)
end
end
defp cancel_timer(socket) do
if is_reference(socket.assigns.timer), do: Process.cancel_timer(socket.assigns.timer)
end
end

View file

@ -0,0 +1,5 @@
defmodule Oban.Web.AccessError do
@moduledoc false
defexception [:message, :reason]
end

301
vendor/oban_web/lib/oban/web/helpers.ex vendored Normal file
View file

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

View file

@ -0,0 +1,24 @@
defmodule Oban.Web.Helpers.QueueHelper do
@moduledoc false
alias Oban.Web.{Queue, Timing}
def executing_count(checks) do
checks
|> List.wrap()
|> Enum.map(&length(&1["running"]))
|> Enum.sum()
end
def started_at(%{checks: _} = queue) do
queue
|> Queue.started_at()
|> Timing.to_words()
end
def started_at(checks) do
%Queue{checks: checks}
|> Queue.started_at()
|> Timing.to_words()
end
end

View file

@ -0,0 +1,59 @@
defmodule Oban.Web.ConnectivityComponent do
use Oban.Web, :live_component
alias Oban.{Met, Notifier}
@refresh :timer.seconds(15)
@impl Phoenix.LiveComponent
def update(assigns, socket) do
checks = Met.checks(assigns.conf.name)
status =
assigns.conf.name
|> Notifier.status()
|> determine_status(checks)
title =
case status do
:isolated -> "Node is isolated: Updates are disabled"
:solitary -> "Node is solitary: Not connected to any cluster"
:disconnected -> "Node is disconnected: No metrics, queues, or nodes detected"
_ -> ""
end
socket =
socket
|> assign(conf: assigns.conf, id: assigns.id)
|> assign(status: status, title: title)
if connected?(socket) do
send_update_after(__MODULE__, %{socket.assigns | status: :reset}, @refresh)
end
{:ok, socket}
end
defp determine_status(pubsub, checks) do
cond do
Enum.empty?(checks) -> :disconnected
pubsub == :isolated -> :isolated
pubsub == :solitary -> :solitary
true -> :connected
end
end
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div id="connectivity" data-title={@title} phx-hook="Tippy">
<Icons.bolt_circle :if={@status == :solitary} class="w-6 h-6 text-yellow-500" />
<Icons.bolt_slash :if={@status == :isolated} class="w-6 h-6 animate-pulse text-red-500" />
<Icons.exclamation_circle
:if={@status == :disconnected}
class="w-6 h-6 animate-pulse text-red-500"
/>
</div>
"""
end
end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,94 @@
defmodule Oban.Web.InstancesComponent do
use Oban.Web, :live_component
alias Oban.Web.Resolver
@impl Phoenix.LiveComponent
def update(assigns, socket) do
instances = available_instances(assigns.resolver, assigns.user)
active = inspect(assigns.conf.name)
socket =
socket
|> assign(conf: assigns.conf, id: assigns.id)
|> assign(resolver: assigns.resolver, user: assigns.user)
|> assign(active: active, instances: instances)
{:ok, socket}
end
defp available_instances(resolver, user) do
instances = oban_instances()
available =
case Resolver.call_with_fallback(resolver, :resolve_instances, [user]) do
:all -> instances
list -> Enum.filter(instances, &(&1 in list))
end
available
|> Enum.map(&inspect/1)
|> Enum.sort()
end
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div class="relative" id="instance-select" phx-hook="Instantiator">
<button
aria-expanded="true"
aria-haspopup="listbox"
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
focus:outline-none focus:ring-blue-500 dark:focus:ring-blue-500"
data-title="Change Oban instance"
id="instance-select-button"
phx-click={JS.toggle(to: "#instance-select-menu")}
phx-hook="Tippy"
type="button"
>
{@active}
</button>
<ul
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
:for={name <- @instances}
class="block w-full flex items-center space-x-2 py-1 px-2 cursor-pointer select-none hover:bg-gray-50 hover:dark:bg-gray-600/30"
role="option"
phx-click="select-instance"
phx-click-away={JS.hide(to: "#instance-select-menu")}
phx-target={@myself}
phx-value-name={name}
>
<%= if name == @active do %>
<Icons.check class="w-4 h-4 text-blue-500" />
<% else %>
<span class="block w-4 h-4"></span>
<% end %>
<span class="text-gray-800 dark:text-gray-200">
{name}
</span>
</li>
</ul>
</div>
"""
end
@impl Phoenix.LiveComponent
def handle_event("select-instance", %{"name" => name}, socket) do
%{resolver: resolver, user: user} = socket.assigns
allowed = Resolver.call_with_fallback(resolver, :resolve_instances, [user])
if allowed == :all or name in Enum.map(allowed, &inspect/1) do
send(self(), {:select_instance, name})
end
{:noreply, socket}
end
end

View file

@ -0,0 +1,308 @@
defmodule Oban.Web.Jobs.ChartComponent do
use Oban.Web, :live_component
alias Oban.Met
alias Oban.Web.Components.Core
alias Oban.Web.Timing
@impl Phoenix.LiveComponent
def mount(socket) do
{:ok, assign(socket, last_os_time: 0, max_cols: 100, max_data: 7)}
end
@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, 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, default_series) end)
|> assign_new(:visible, fn -> init_lazy(:visible, assigns, true) end)
socket =
if socket.assigns.visible do
step = period_to_step(socket.assigns.period)
os_time = Timing.snap(assigns.os_time, step)
points = points(os_time, socket.assigns)
update = %{group: socket.assigns.group, points: points, series: socket.assigns.series}
socket
|> assign(last_os_time: os_time)
|> push_event("chart-change", update)
else
socket
end
{:ok, socket}
end
defp init_lazy(key, %{init_state: init_state}, default) do
Map.get(init_state, "oban:chart-#{key}", default)
end
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<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
id="chart-toggle"
data-title="Toggle charts"
phx-click={toggle_chart(@myself)}
phx-hook="Tippy"
>
<Icons.chevron_right class={[
"w-5 h-5 mr-2 transition-transform",
if(@visible, do: "rotate-90")
]} />
</button>
<h3 class="text-base font-semibold">
{metric_label(@series)}
</h3>
<span class="text-gray-600 dark:text-gray-400 font-light ml-1">
({@period} by {String.capitalize(@group)})
</span>
<span
:if={@params |> params_to_filters() |> Enum.any?()}
id="chart-filtered-alert"
class="w-3 h-3 ml-1 bg-violet-300 rounded-full"
data-title={"Filtered by #{params_to_filters_list(@params)}"}
phx-hook="Tippy"
>
</span>
</div>
<div id="chart-c" class="flex space-x-2">
<Core.dropdown_button
disabled={not @visible}
name="series"
options={series()}
selected={@series}
target={@myself}
title="Change metric series"
>
<Icons.chart_bar_square />
</Core.dropdown_button>
<Core.dropdown_button
disabled={not @visible}
name="period"
options={periods()}
selected={@period}
target={@myself}
title="Change slice period"
>
<Icons.clock />
</Core.dropdown_button>
<Core.dropdown_button
disabled={not @visible}
name="group"
options={groups_for_series(@series)}
selected={@group}
target={@myself}
title="Change metric grouping"
>
<Icons.rectangle_group />
</Core.dropdown_button>
<Core.dropdown_button
disabled={not @visible or @series in ~w(exec_count full_count)}
name="ntile"
options={ntiles()}
selected={@ntile}
target={@myself}
title="Change percentile"
>
<Icons.percent_square />
</Core.dropdown_button>
</div>
</div>
<div
id="chart"
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="JobsChart"></canvas>
</div>
</div>
"""
end
# Data
defp points(os_time, assigns) do
step = period_to_step(assigns.period)
cols = assigns.max_cols
sy_time = Timing.snap(System.system_time(:second), step)
filters = params_to_filters(assigns.params)
opts = [
by: step,
filters: filters,
group: assigns.group,
lookback: cols * step,
operation: ntile_to_operation(assigns.ntile),
since: sy_time
]
assigns.conf.name
|> Met.timeslice(String.to_existing_atom(assigns.series), opts)
|> Enum.group_by(&elem(&1, 2), &Tuple.delete_at(&1, 2))
|> top_n(assigns.max_data)
|> Map.new(fn {label, slices} -> {label, interpolate(slices, cols, step, os_time)} end)
end
defp top_n(points, limit) do
points
|> Enum.sort_by(fn {_key, data} -> Enum.reduce(data, 0, &(elem(&1, 1) + &2)) end, :desc)
|> Enum.take(limit)
end
defp interpolate(slices, cols, step, time) do
lookup = Map.new(slices)
for index <- 0..(cols - 1) do
x = time - step * index
y = Map.get(lookup, index, nil)
%{x: to_string(x), y: y}
end
end
# Events
@impl Phoenix.LiveComponent
def handle_event("select-group", %{"choice" => group}, socket) do
{:noreply, push_change(socket, group: group)}
end
def handle_event("select-ntile", %{"choice" => ntile}, socket) do
{:noreply, push_change(socket, ntile: ntile)}
end
def handle_event("select-period", %{"choice" => period}, socket) do
{:noreply, push_change(socket, period: period)}
end
def handle_event("select-series", %{"choice" => series}, socket) do
assigns =
cond do
series == "full_count" and socket.assigns.group in ~w(node worker) ->
[ntile: "max", group: "state", series: series]
series == "full_count" ->
[ntile: "max", series: series]
series in ~w(exec_time wait_time) ->
[ntile: "p95", series: series]
true ->
[ntile: "sum", series: series]
end
{:noreply, push_change(socket, assigns)}
end
def handle_event("toggle-visible", _params, socket) do
socket =
if socket.assigns.visible do
push_change(socket, visible: false)
else
push_change(socket, visible: true)
end
{:noreply, socket}
end
defp push_change(socket, change) do
os_time = socket.assigns.last_os_time
socket = assign(socket, change)
points = points(os_time, Map.put(socket.assigns, :last_os_time, 0))
update = %{
group: socket.assigns.group,
ntile: socket.assigns.ntile,
period: socket.assigns.period,
points: points,
series: socket.assigns.series,
visible: socket.assigns.visible
}
push_event(socket, "chart-change", update)
end
# Lookups
def groups, do: ~w(state queue node worker)
def ntiles, do: ~w(max p99 p95 p75 p50)
def periods, do: ~w(1s 5s 10s 30s 1m 2m)
def series, do: ~w(exec_count full_count exec_time wait_time)
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"
defp metric_label("wait_time"), do: "Queue Time"
defp ntile_to_operation("sum"), do: :sum
defp ntile_to_operation("max"), do: :max
defp ntile_to_operation("p99"), do: {:pct, 0.99}
defp ntile_to_operation("p95"), do: {:pct, 0.95}
defp ntile_to_operation("p75"), do: {:pct, 0.75}
defp ntile_to_operation("p50"), do: {:pct, 0.50}
defp period_to_step("1s"), do: 1
defp period_to_step("5s"), do: 5
defp period_to_step("10s"), do: 10
defp period_to_step("30s"), do: 30
defp period_to_step("1m"), do: 60
defp period_to_step("2m"), do: 120
@filterable_params ~w(nodes queues workers)a
defp params_to_filters(params) do
for {key, vals} <- params, key in @filterable_params do
case key do
:nodes -> {:node, vals}
:queues -> {:queue, vals}
:workers -> {:worker, vals}
end
end
end
defp params_to_filters_list(params) do
params
|> Map.take(@filterable_params)
|> Map.keys()
|> Enum.sort()
|> Enum.join(", ")
end
# JS Commands
defp toggle_chart(target) do
%JS{}
|> JS.toggle(in: "fade-in-scale", out: "fade-out-scale", to: "#chart")
|> JS.add_class("rotate-90", to: "#chart-toggle svg:not(.rotate-90)")
|> JS.remove_class("rotate-90", to: "#chart-toggle svg.rotate-90")
|> JS.push("toggle-visible", target: target)
end
end

View file

@ -0,0 +1,996 @@
defmodule Oban.Web.Jobs.DetailComponent do
use Oban.Web, :live_component
import Oban.Web.FormComponents
alias Oban.Web.Jobs.{HistoryChartComponent, TimelineComponent}
alias Oban.Web.{Resolver, Timing}
@impl Phoenix.LiveComponent
def update(assigns, socket) do
auto_open_diagnostics? =
assigns[:diagnostics] != nil and
socket.assigns[:diagnostics] == nil and
is_struct(assigns[:job]) and assigns.job.state == "executing"
socket =
socket
|> assign(assigns)
|> assign_new(:error_index, fn -> 0 end)
|> assign_new(:error_sort, fn -> :desc end)
|> assign_new(:edit_changed?, fn -> false end)
|> assign_new(:queues, fn -> [] end)
|> assign_new(:diagnostics_open?, fn -> false end)
|> assign_new(:form, fn -> form_from_job(assigns.job) end)
|> then(fn socket ->
if auto_open_diagnostics?, do: assign(socket, :diagnostics_open?, true), else: socket
end)
{:ok, socket}
end
defp form_from_job(job) do
%{
worker: job.worker,
queue: job.queue,
priority: job.priority,
max_attempts: job.max_attempts,
scheduled_at: format_datetime(job.scheduled_at),
tags: format_job_tags(job.tags),
args: format_job_args(job.args)
}
end
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div id="job-details">
<div class="flex justify-between items-center px-3 py-4 border-b border-gray-200 dark:border-gray-700">
<button
id="back-link"
class="flex items-center hover:text-blue-500 cursor-pointer bg-transparent border-0 p-0"
data-escape-back={true}
data-title="Back to jobs"
phx-hook="HistoryBack"
type="button"
>
<Icons.arrow_left class="w-5 h-5" />
<span class="text-lg font-bold ml-2">{job_title(@job)}</span>
</button>
<div class="flex space-x-3">
<Core.status_badge :if={@job.meta["batch"]} icon="square_2x2" label="Batch" />
<Core.status_badge :if={@job.meta["workflow"]} icon="rectangle_group" label="Workflow" />
<Core.status_badge :if={@job.meta["chunk"]} icon="user_group" label="Chunk" />
<Core.status_badge :if={@job.meta["chain"]} icon="link" label="Chain" />
<Core.status_badge :if={@job.meta["recorded"]} icon="camera" label="Recorded" />
<Core.status_badge :if={@job.meta["encrypted"]} icon="lock_closed" label="Encrypted" />
<Core.status_badge :if={@job.meta["structured"]} icon="table_cells" label="Structured" />
<Core.status_badge :if={@job.meta["decorated"]} icon="sparkles" label="Decorated" />
<Core.status_badge :if={@job.meta["rescued"]} icon="life_buoy" label="Rescued" />
<Core.icon_button
id="detail-cancel"
icon="x_circle"
label="Cancel"
color="yellow"
tooltip="Cancel this job"
disabled={not cancelable?(@job)}
phx-target={@myself}
phx-click="cancel"
/>
<Core.icon_button
id="detail-retry"
icon="arrow_path"
label="Retry"
color="blue"
tooltip="Retry this job"
disabled={not (runnable?(@job) or retryable?(@job))}
phx-target={@myself}
phx-click="retry"
/>
<Core.icon_button
id="detail-delete"
icon="trash"
label="Delete"
color="red"
tooltip="Delete this job"
disabled={not deletable?(@job)}
confirm="Are you sure you want to delete this job?"
phx-target={@myself}
phx-click="delete"
/>
<Core.icon_button
id="detail-edit"
icon="pencil_square"
label="Edit"
color="violet"
tooltip="Edit this job"
disabled={not editable?(@job)}
phx-click={scroll_to_edit()}
/>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-5 gap-6 px-3 pt-6">
<div class="lg:col-span-3">
<TimelineComponent.render job={@job} os_time={@os_time} />
</div>
<div class="lg:col-span-2">
<div class="grid grid-cols-3 gap-4 mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
ID
</span>
<span class="text-base text-gray-800 dark:text-gray-200 tabular">
{@job.id}
</span>
</div>
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Wait Time
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
{Timing.queue_time(@job)}
</span>
</div>
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Exec Time
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
{Timing.run_time(@job)}
</span>
</div>
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Attempted By
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
{attempted_by(@job)}
</span>
</div>
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Snoozed
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
{@job.meta["snoozed"] || ""}
</span>
</div>
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Rescued
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
{@job.meta["rescued"] || ""}
</span>
</div>
</div>
<div class="grid grid-cols-3 gap-4 mb-4 px-3">
<.link
id="queue-link"
patch={oban_path([:queues, @job.queue])}
class="flex flex-col -m-2 p-2 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
data-title="View queue details"
phx-hook="Tippy"
>
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Queue
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
{@job.queue}
</span>
</.link>
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Attempt
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
{@job.attempt} of {@job.max_attempts}
</span>
</div>
<div class="flex flex-col">
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Priority
</span>
<span class="text-base text-gray-800 dark:text-gray-200">
{@job.priority}
</span>
</div>
<.link
:if={@job.meta["workflow_id"]}
id="workflow-link"
navigate={oban_path([:workflows, @job.meta["workflow_id"]])}
class="flex flex-col col-span-3 -m-2 p-2 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
data-title="View workflow"
phx-hook="Tippy"
>
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
Workflow
</span>
<span class="text-base text-gray-800 dark:text-gray-200 flex items-center">
<Icons.rectangle_group class="w-4 h-4 mr-1.5 text-violet-500" />
<span class="truncate">{workflow_display_name(@job)}</span>
</span>
</.link>
</div>
</div>
</div>
<.job_data_section job={@job} resolver={@resolver} />
<div class="px-3 py-6 border-t border-gray-200 dark:border-gray-700">
<button
id="diagnostics-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-diagnostics"
phx-target={@myself}
>
<Icons.chevron_right
id="diagnostics-chevron"
class={["w-5 h-5 transition-transform", if(@diagnostics_open?, do: "rotate-90")]}
/>
<span class="font-semibold">Diagnostics</span>
<.pro_badge id="diagnostics-badge" tooltip="Diagnostics for executing Oban.Pro.Worker jobs" />
<.stale_badge :if={@diagnostics && not executing?(@job)} />
</button>
<div :if={@diagnostics_open?} id="diagnostics-content" class="mt-3">
<%= if @diagnostics do %>
<div class="grid grid-cols-2 gap-4">
<div class="bg-gray-50 dark:bg-gray-800 rounded-md p-4">
<div class="flex justify-between items-center mb-3">
<h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
Process Info
</h4>
<span class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
Refreshed at {format_diagnostics_time(@diagnostics_at)}
</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col">
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">Status</span>
<span class="text-sm tabular-nums text-gray-800 dark:text-gray-200">
{format_status(@diagnostics["info"]["status"])}
</span>
</div>
<div class="flex flex-col">
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">Memory</span>
<span class="text-sm tabular-nums text-gray-800 dark:text-gray-200">
{format_bytes(@diagnostics["info"]["memory"])}
</span>
</div>
<div class="flex flex-col">
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">
Message Queue
</span>
<span class="text-sm tabular-nums text-gray-800 dark:text-gray-200">
{format_number(@diagnostics["info"]["message_queue_len"])}
</span>
</div>
<div class="flex flex-col">
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">
Reductions
</span>
<span class="text-sm tabular-nums text-gray-800 dark:text-gray-200">
{format_number(@diagnostics["info"]["reductions"])}
</span>
</div>
<div class="flex flex-col">
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">
Heap Size
</span>
<span class="text-sm tabular-nums text-gray-800 dark:text-gray-200">
{format_number(@diagnostics["info"]["heap_size"])}
</span>
</div>
<div class="flex flex-col">
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">
Stack Size
</span>
<span class="text-sm tabular-nums text-gray-800 dark:text-gray-200">
{format_number(@diagnostics["info"]["stack_size"])}
</span>
</div>
</div>
</div>
<div class="relative bg-gray-50 dark:bg-gray-800 rounded-md p-4">
<div class="flex justify-between items-start mb-3">
<h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
Current Stacktrace
</h4>
<button
:if={@diagnostics["info"]["current_stacktrace"]}
type="button"
id="copy-stacktrace"
class="w-9 h-9 -mr-2 -mt-2 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-white dark:hover:bg-gray-700 cursor-pointer"
data-title="Copy to clipboard"
phx-hook="Tippy"
phx-click={copy_to_clipboard(@diagnostics["info"]["current_stacktrace"])}
>
<Icons.clipboard class="w-4 h-4" />
</button>
</div>
<%= if @diagnostics["info"]["current_stacktrace"] do %>
<div class="space-y-1 max-h-64 overflow-y-auto">
<div
:for={frame <- parse_stacktrace(@diagnostics["info"]["current_stacktrace"])}
class="font-mono text-xs text-gray-600 dark:text-gray-400 py-1.5 px-2 bg-white dark:bg-gray-900 rounded border-l-2 border-gray-300 dark:border-gray-600"
>
{frame}
</div>
</div>
<% else %>
<span class="text-sm text-gray-400 dark:text-gray-500">
No stacktrace available
</span>
<% end %>
</div>
</div>
<% else %>
<div class="flex items-center space-x-2 px-2 text-gray-400 dark:text-gray-500">
<Icons.clock class="w-5 h-5" />
<span class="text-sm">
<%= if executing?(@job) do %>
Waiting for diagnostics...
<% else %>
Diagnostics are only available for executing jobs
<% end %>
</span>
</div>
<% end %>
</div>
</div>
<div class="px-3 py-6 border-t border-gray-200 dark:border-gray-700">
<.live_component
id="detail-history-chart"
module={HistoryChartComponent}
job={@job}
history={@history}
/>
</div>
<div class="px-3 py-6 border-t border-gray-200 dark:border-gray-700">
<button
id="errors-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_errors()}
>
<Icons.chevron_right
id="errors-chevron"
class={["w-5 h-5 transition-transform", if(Enum.any?(@job.errors), do: "rotate-90")]}
/>
<span class="font-semibold">
Errors
<span :if={Enum.any?(@job.errors)} class="text-gray-400 font-normal">
({length(@job.errors)})
</span>
</span>
</button>
<div id="errors-content" class={["mt-3", if(Enum.empty?(@job.errors), do: "hidden")]}>
<%= if Enum.any?(@job.errors) do %>
<div class="flex items-center justify-end mb-3 space-x-4">
<div class="flex items-center text-sm">
<button
type="button"
phx-click="error-sort"
phx-value-sort="desc"
phx-target={@myself}
class={[
"px-2 py-1 cusror-pointer rounded-l-md border border-r-0 border-gray-300 dark:border-gray-600",
if(@error_sort == :desc,
do: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200",
else:
"bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-750"
)
]}
>
Newest
</button>
<button
type="button"
phx-click="error-sort"
phx-value-sort="asc"
phx-target={@myself}
class={[
"px-2 py-1 cusror-pointer rounded-r-md border border-gray-300 dark:border-gray-600",
if(@error_sort == :asc,
do: "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200",
else:
"bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-750"
)
]}
>
Oldest
</button>
</div>
<div class="flex items-center space-x-1">
<button
type="button"
phx-click="error-nav"
phx-value-dir="prev"
phx-target={@myself}
disabled={@error_index == 0}
class={[
"p-1 rounded",
if(@error_index == 0,
do: "text-gray-300 dark:text-gray-500 cursor-not-allowed",
else:
"text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
)
]}
>
<Icons.chevron_left class="w-5 h-5" />
</button>
<span class="text-sm text-gray-500 dark:text-gray-400 tabular min-w-[4rem] text-center">
{@error_index + 1} of {length(@job.errors)}
</span>
<button
type="button"
phx-click="error-nav"
phx-value-dir="next"
phx-target={@myself}
disabled={@error_index >= length(@job.errors) - 1}
class={[
"p-1 rounded",
if(@error_index >= length(@job.errors) - 1,
do: "text-gray-300 dark:text-gray-500 cursor-not-allowed",
else:
"text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
)
]}
>
<Icons.chevron_right class="w-5 h-5" />
</button>
</div>
</div>
<.error_entry errors={@job.errors} index={@error_index} sort={@error_sort} />
<% else %>
<div class="flex items-center space-x-2 px-2 text-gray-400 dark:text-gray-500">
<Icons.check_circle class="w-5 h-5" />
<span class="text-sm">No errors recorded</span>
</div>
<% end %>
</div>
</div>
<div class="px-3 py-6 border-t border-gray-200 dark:border-gray-700">
<button
id="edit-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_edit()}
>
<Icons.chevron_right
id="edit-chevron"
class={["w-5 h-5 transition-transform", if(editable?(@job), do: "rotate-90")]}
/>
<span class="font-semibold">Edit Job</span>
<span
:if={not editable?(@job)}
id="edit-hint"
class="flex items-center"
data-title="Only available, scheduled, and retryable jobs can be edited"
phx-hook="Tippy"
>
<Icons.info_circle class="w-4 h-4 text-gray-400" />
</span>
</button>
<div id="edit-content" class={["mt-3", unless(editable?(@job), do: "hidden")]}>
<fieldset disabled={not editable?(@job)}>
<form
id="job-edit-form"
class="grid grid-cols-4 gap-4 bg-gray-50 dark:bg-gray-800 rounded-md p-4"
phx-change="edit-change"
phx-submit="save-job"
phx-target={@myself}
>
<.form_field label="Worker" name="worker" value={@form.worker} />
<.select_field
label="Queue"
name="queue"
value={@form.queue}
options={queue_options(@queues)}
/>
<.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"
/>
<.form_field
label="Scheduled At"
name="scheduled_at"
value={@form.scheduled_at}
type="datetime-local"
/>
<.form_field
label="Tags"
name="tags"
value={@form.tags}
placeholder="tag1, tag2"
colspan="col-span-3"
/>
<.form_field
label="Args"
name="args"
value={@form.args}
colspan="col-span-4"
type="textarea"
placeholder="{}"
rows={3}
/>
<div class="col-span-4 flex justify-end items-center gap-3 pt-4">
<button
type="button"
phx-click={cancel_edit(@myself)}
class="px-6 py-2 text-gray-600 dark:text-gray-400 text-sm font-medium rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={not @edit_changed?}
class="px-6 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
Save Changes
</button>
</div>
</form>
</fieldset>
</div>
</div>
</div>
"""
end
# Pro Badge
attr :id, :string, required: true
attr :tooltip, :string, required: true
defp pro_badge(assigns) do
~H"""
<span
id={@id}
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"
data-title={@tooltip}
phx-hook="Tippy"
>
Pro
</span>
"""
end
defp stale_badge(assigns) do
~H"""
<span
id="diagnostics-stale-badge"
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300"
data-title="Diagnostics data is stale because the job is no longer executing"
phx-hook="Tippy"
>
Stale
</span>
"""
end
# Job Data Section
attr :job, :map, required: true
attr :resolver, :any, required: true
defp job_data_section(assigns) do
~H"""
<div class="px-3 py-6 border-t border-gray-200 dark:border-gray-700">
<button
id="job-data-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_job_data()}
>
<Icons.chevron_right id="job-data-chevron" class="w-5 h-5 transition-transform rotate-90" />
<span class="font-semibold">Job Data</span>
</button>
<div id="job-data-content" class="mt-3">
<div class="grid grid-cols-2 gap-4">
<div id="job-args" class="relative bg-gray-50 dark:bg-gray-800 rounded-md p-4">
<div class="flex justify-between items-start mb-2">
<h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
Args
</h4>
<button
type="button"
id="copy-args"
class="w-9 h-9 -mr-2 -mt-2 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-white dark:hover:bg-gray-700 cursor-pointer"
data-title="Copy to clipboard"
phx-hook="Tippy"
phx-click={copy_to_clipboard(format_args(@job, @resolver))}
>
<Icons.clipboard class="w-4 h-4" />
</button>
</div>
<pre class="font-mono text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-all">{format_args(@job, @resolver)}</pre>
</div>
<div class="relative bg-gray-50 dark:bg-gray-800 rounded-md p-4">
<div class="flex justify-between items-start mb-2">
<h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
Meta
</h4>
<button
type="button"
id="copy-meta"
class="w-9 h-9 -mr-2 -mt-2 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-white dark:hover:bg-gray-700 cursor-pointer"
data-title="Copy to clipboard"
phx-hook="Tippy"
phx-click={copy_to_clipboard(format_meta(@job, @resolver))}
>
<Icons.clipboard class="w-4 h-4" />
</button>
</div>
<pre class="font-mono text-sm text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-all">{format_meta(@job, @resolver)}</pre>
</div>
</div>
<div :if={@job.meta["recorded"]} class="mt-4">
<div class="relative bg-gray-50 dark:bg-gray-800 rounded-md p-4">
<div class="flex justify-between items-start mb-2">
<div class="flex items-center space-x-2">
<h4 class="font-medium text-xs uppercase text-gray-500 dark:text-gray-400">
Recorded Output
</h4>
<.pro_badge id="recorded-pro-badge" tooltip="Recording from Oban.Pro.Worker" />
</div>
<button
type="button"
id="copy-recorded"
class="w-9 h-9 -mr-2 -mt-2 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-white dark:hover:bg-gray-700 cursor-pointer"
data-title="Copy to clipboard"
phx-hook="Tippy"
phx-click={copy_to_clipboard(format_recorded(@job, @resolver))}
>
<Icons.clipboard class="w-4 h-4" />
</button>
</div>
<pre class="font-mono text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-all">{format_recorded(@job, @resolver)}</pre>
</div>
</div>
</div>
</div>
"""
end
# Handlers
@impl Phoenix.LiveComponent
def handle_event("cancel", _params, socket) do
if can?(:cancel_jobs, socket.assigns.access) do
send(self(), {:cancel_job, socket.assigns.job})
end
{:noreply, socket}
end
def handle_event("delete", _params, socket) do
if can?(:delete_jobs, socket.assigns.access) do
send(self(), {:delete_job, socket.assigns.job})
end
{:noreply, socket}
end
def handle_event("retry", _params, socket) do
if can?(:retry_jobs, socket.assigns.access) do
send(self(), {:retry_job, socket.assigns.job})
end
{:noreply, socket}
end
def handle_event("toggle-diagnostics", _params, socket) do
{:noreply, assign(socket, :diagnostics_open?, not socket.assigns.diagnostics_open?)}
end
def handle_event("error-sort", %{"sort" => sort}, socket) do
sort = String.to_existing_atom(sort)
{:noreply, assign(socket, error_sort: sort, error_index: 0)}
end
def handle_event("error-nav", %{"dir" => "prev"}, socket) do
index = max(0, socket.assigns.error_index - 1)
{:noreply, assign(socket, error_index: index)}
end
def handle_event("error-nav", %{"dir" => "next"}, socket) do
max_index = length(socket.assigns.job.errors) - 1
index = min(max_index, socket.assigns.error_index + 1)
{:noreply, assign(socket, error_index: index)}
end
def handle_event("edit-change", params, socket) do
job = Map.update!(socket.assigns.job, :scheduled_at, &DateTime.truncate(&1, :second))
form = %{
worker: params["worker"],
queue: params["queue"],
priority: params["priority"],
max_attempts: params["max_attempts"],
scheduled_at: params["scheduled_at"],
tags: params["tags"],
args: params["args"]
}
changed? =
params
|> parse_edit_params(job)
|> Enum.any?(fn {_key, val} -> not is_nil(val) end)
{:noreply, assign(socket, form: form, edit_changed?: changed?)}
end
def handle_event("cancel-edit", _params, socket) do
form = form_from_job(socket.assigns.job)
{:noreply, assign(socket, form: form, edit_changed?: false)}
end
def handle_event("save-job", params, socket) do
job = socket.assigns.job
changes =
params
|> parse_edit_params(job)
|> Enum.reject(fn {_key, val} -> is_nil(val) end)
|> Map.new()
if map_size(changes) > 0 do
send(self(), {:update_job, job, changes})
end
{:noreply, assign(socket, edit_changed?: false)}
end
# Helpers
defp format_args(job, resolver) do
Resolver.call_with_fallback(resolver, :format_job_args, [job])
end
defp format_meta(%{meta: meta} = job, resolver) do
job =
if meta["recorded"] do
%{job | meta: Map.delete(meta, "return")}
else
job
end
Resolver.call_with_fallback(resolver, :format_job_meta, [job])
end
defp format_recorded(%{meta: meta} = job, resolver) do
case meta do
%{"recorded" => true, "return" => value} ->
Resolver.call_with_fallback(resolver, :format_recorded, [value, job])
%{"recorded" => true} ->
"No Recording Yet"
_ ->
"Recording Not Enabled"
end
end
defp error_entry(assigns) do
error =
assigns.errors
|> Enum.sort_by(& &1["attempt"], assigns.sort)
|> Enum.at(assigns.index)
{message, stacktrace} = parse_error(error["error"])
assigns = assign(assigns, error: error, message: message, stacktrace: stacktrace)
~H"""
<div class="mb-8 p-4 bg-gray-50 dark:bg-gray-800 rounded-md">
<div class="flex items-center justify-between mb-3 text-sm text-gray-500 dark:text-gray-400">
<span>Attempt {@error["attempt"]}</span>
<span>
{Timing.datetime_to_words(@error["at"])}
<span class="text-gray-400 dark:text-gray-500">({@error["at"]})</span>
</span>
</div>
<div class="font-mono text-base font-medium text-gray-800 dark:text-gray-200 mb-4">
{@message}
</div>
<div :if={@stacktrace != []} class="space-y-1">
<div
:for={frame <- @stacktrace}
class="font-mono text-sm text-gray-600 dark:text-gray-400 py-1.5 px-2 bg-white dark:bg-gray-900 rounded border-l-2 border-gray-300 dark:border-gray-600"
>
{frame}
</div>
</div>
</div>
"""
end
defp parse_error(error) do
case String.split(error, "\n", parts: 2) do
[message, rest] ->
stacktrace =
rest
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
{message, stacktrace}
[message] ->
{message, []}
end
end
defp toggle_errors do
%JS{}
|> JS.toggle(to: "#errors-content", in: "fade-in-scale", out: "fade-out-scale")
|> JS.add_class("rotate-90", to: "#errors-chevron:not(.rotate-90)")
|> JS.remove_class("rotate-90", to: "#errors-chevron.rotate-90")
end
defp toggle_job_data do
%JS{}
|> JS.toggle(to: "#job-data-content", in: "fade-in-scale", out: "fade-out-scale")
|> JS.add_class("rotate-90", to: "#job-data-chevron:not(.rotate-90)")
|> JS.remove_class("rotate-90", to: "#job-data-chevron.rotate-90")
end
defp job_title(job), do: Map.get(job.meta, "decorated_name", job.worker)
defp workflow_display_name(job) do
job.meta["workflow_name"] || job.meta["workflow_id"]
end
defp toggle_edit do
%JS{}
|> JS.toggle(to: "#edit-content", in: "fade-in-scale", out: "fade-out-scale")
|> JS.add_class("rotate-90", to: "#edit-chevron:not(.rotate-90)")
|> JS.remove_class("rotate-90", to: "#edit-chevron.rotate-90")
end
defp scroll_to_edit do
%JS{}
|> JS.show(to: "#edit-content", transition: "fade-in-scale")
|> JS.add_class("rotate-90", to: "#edit-chevron")
|> JS.focus(to: "#job-edit-form input")
end
defp cancel_edit(target) do
%JS{}
|> JS.hide(to: "#edit-content", transition: "fade-out-scale")
|> JS.remove_class("rotate-90", to: "#edit-chevron")
|> JS.dispatch("reset", to: "#job-edit-form")
|> JS.push("cancel-edit", target: target)
end
defp copy_to_clipboard(text) do
JS.dispatch("phx:copy-to-clipboard", detail: %{text: text})
end
defp editable?(%{state: state}) do
state in ~w(scheduled retryable available)
end
defp executing?(%{state: state}), do: state == "executing"
defp format_bytes(nil), do: ""
defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B"
defp format_bytes(bytes) when bytes < 1024 * 1024, do: "#{Float.round(bytes / 1024, 1)} KB"
defp format_bytes(bytes), do: "#{Float.round(bytes / 1024 / 1024, 1)} MB"
defp format_number(nil), do: ""
defp format_number(num) when is_integer(num), do: integer_to_delimited(num)
defp format_status(nil), do: ""
defp format_status(status) when is_binary(status), do: String.capitalize(status)
defp format_diagnostics_time(unix_time) when is_integer(unix_time) do
unix_time
|> DateTime.from_unix!()
|> Calendar.strftime("%H:%M:%S")
end
defp parse_stacktrace(nil), do: []
defp parse_stacktrace(stacktrace) when is_binary(stacktrace) do
stacktrace
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp parse_edit_params(params, job) do
[
worker: new_val?(parse_string(params["worker"]), job.worker),
queue: new_val?(parse_string(params["queue"]), job.queue),
priority: new_val?(parse_int(params["priority"]), job.priority),
max_attempts: new_val?(parse_int(params["max_attempts"]), job.max_attempts),
scheduled_at: new_val?(parse_datetime(params["scheduled_at"]), job.scheduled_at),
tags: new_val?(parse_tags(params["tags"]), job.tags),
args: new_val?(parse_json(params["args"]), job.args)
]
end
defp new_val?(nil, _current), do: nil
defp new_val?("", _current), do: nil
defp new_val?(val, val), do: nil
defp new_val?(val, _current), do: val
defp parse_datetime(nil), do: nil
defp parse_datetime(""), do: nil
defp parse_datetime(str) when is_binary(str) do
case DateTime.from_iso8601(str <> "Z") do
{:ok, datetime, 0} -> datetime
_ -> nil
end
end
defp format_datetime(nil), do: nil
defp format_datetime(%DateTime{} = datetime) do
datetime
|> DateTime.truncate(:second)
|> DateTime.to_iso8601()
|> String.slice(0, 19)
end
defp format_job_tags(nil), do: nil
defp format_job_tags([]), do: nil
defp format_job_tags(tags) when is_list(tags), do: Enum.join(tags, ", ")
defp format_job_args(args) when is_map(args), do: Oban.JSON.encode!(args)
defp format_job_args(_), do: "{}"
end

View file

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

View file

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

View file

@ -0,0 +1,50 @@
defmodule Oban.Web.Jobs.SidebarComponent do
use Oban.Web, :html
alias Oban.Web.Queue
alias Oban.Web.SidebarComponents
attr :nodes, :list
attr :params, :map
attr :queues, :list
attr :states, :list
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="states" headers={~w(count)}>
<SidebarComponents.filter_row
:for={state <- @states}
name={state.name}
exclusive={true}
active={active_filter?(@params, :state, state.name)}
patch={patch_params(@params, :jobs, :state, state.name)}
values={[state.count]}
/>
</SidebarComponents.section>
<SidebarComponents.section name="nodes" headers={~w(exec limit)}>
<SidebarComponents.filter_row
:for={node <- @nodes}
name={node.name}
active={active_filter?(@params, :nodes, node.name)}
patch={patch_params(@params, :jobs, :nodes, node.name)}
values={[node.count, node.limit]}
/>
</SidebarComponents.section>
<SidebarComponents.section name="queues" headers={~w(mode limit exec avail)}>
<SidebarComponents.filter_row
:for={queue <- @queues}
name={queue.name}
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]}
/>
</SidebarComponents.section>
</SidebarComponents.sidebar>
"""
end
end

View file

@ -0,0 +1,246 @@
defmodule Oban.Web.Jobs.TableComponent do
use Oban.Web, :live_component
alias Oban.Web.Resolver
@inc_limit 20
@max_limit 200
@min_limit 20
@impl Phoenix.LiveComponent
def update(assigns, socket) do
producers =
assigns.conf.name
|> Oban.Met.checks()
|> Enum.map(& &1["uuid"])
|> MapSet.new()
socket =
socket
|> assign(jobs: assigns.jobs, params: assigns.params)
|> assign(query_limit: query_limit(assigns.resolver, assigns.params))
|> assign(producers: producers, resolver: assigns.resolver, selected: assigns.selected)
|> assign(show_less?: assigns.params.limit > @min_limit)
|> assign(show_more?: assigns.params.limit < @max_limit)
{:ok, socket}
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 pl-4"]}>
{@label}
</span>
"""
end
@impl Phoenix.LiveComponent
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-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" />
</ul>
<div :if={Enum.empty?(@jobs)} class="text-lg text-center py-12">
<div class="flex items-center justify-center space-x-2 text-gray-600 dark:text-gray-300">
<Icons.no_symbol /> <span>No jobs match the current set of filters.</span>
</div>
<p :if={is_integer(@query_limit)} class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Filtering limited to latest {integer_to_delimited(@query_limit)} jobs. See <a
class="underline"
href="https://oban.pro/docs/web/filtering.html"
>filtering docs</a>.
</p>
</div>
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
<.job_row
:for={job <- @jobs}
job={job}
myself={@myself}
producers={@producers}
resolver={@resolver}
selected={@selected}
/>
</ul>
<div 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>
</div>
"""
end
defp job_row(assigns) do
~H"""
<li
id={"job-#{@job.id}"}
class={["flex items-center hover:bg-gray-50 dark:hover:bg-gray-950/30", hidden_class(@job)]}
>
<Core.row_checkbox
click="toggle-select"
value={@job.id}
checked={MapSet.member?(@selected, @job.id)}
myself={@myself}
/>
<.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">
{Map.get(@job.meta, "decorated_name", @job.worker)}
</span>
<span class="tabular text-xs text-gray-600 dark:text-gray-300" rel="attempts">
{@job.attempt} {@job.max_attempts}
</span>
<samp class="ml-2 font-mono truncate text-xs text-gray-500 dark:text-gray-400" rel="args">
{format_args(@job, @resolver)}
</samp>
</div>
<div class="ml-auto flex items-center space-x-1">
<Icons.life_buoy
:if={Map.has_key?(@job.meta, "rescued")}
class="h-5 w-5 text-gray-500 dark:text-gray-300"
id={"job-rescued-#{assigns.job.id}"}
phx-hook="Tippy"
data-title="Rescued by lifeline"
/>
<Icons.crossbones_circle_solid
:if={orphaned?(@job, @producers)}
class="h-5 w-5 text-gray-500 dark:text-gray-300"
id={"job-orphaned-#{assigns.job.id}"}
phx-hook="Tippy"
data-title="Orphaned, host node shut down"
/>
<span class="py-1.5 px-2 tabular truncate text-xs rounded-md bg-gray-100 dark:bg-gray-950">
{@job.queue}
</span>
</div>
<div
class="w-20 pr-3 text-sm text-right tabular text-gray-500 dark:text-gray-300 dark:group-hover:text-gray-100"
data-timestamp={timestamp(@job)}
data-relative-mode={relative_mode(@job)}
id={"job-ts-#{@job.id}"}
phx-hook="Relativize"
phx-update="ignore"
>
00:00
</div>
</.link>
</li>
"""
end
defp load_button(assigns) do
~H"""
<button
type="button"
class={"font-semibold text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 #{loader_class(@active)}"}
phx-target={@myself}
phx-click={@click}
>
{@label}
</button>
"""
end
@impl Phoenix.LiveComponent
def handle_event("toggle-select", %{"id" => id}, socket) do
send(self(), {:toggle_select, String.to_integer(id)})
{: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
# Resolver Helpers
defp query_limit(resolver, params) do
state = String.to_existing_atom(params.state)
Resolver.call_with_fallback(resolver, :jobs_query_limit, [state])
end
defp format_args(job, resolver) do
resolver
|> Resolver.call_with_fallback(:format_job_args, [job])
|> truncate(0..98)
end
# Time Helpers
defp timestamp(job) do
datetime =
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
DateTime.to_unix(datetime, :millisecond)
else
"-"
end
end
defp relative_mode(job) do
if job.state == "executing", do: "duration", else: "words"
end
# Class Helpers
defp hidden_class(%{hidden?: true}), do: "opacity-25 pointer-events-none"
defp hidden_class(_job), do: ""
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"
end

View file

@ -0,0 +1,255 @@
defmodule Oban.Web.Jobs.TimelineComponent do
@moduledoc false
use Phoenix.Component
alias Oban.Web.Colors
alias Oban.Web.Components.Icons
alias Oban.Web.Timing
def render(assigns) do
assigns =
assigns
|> assign(:path, compute_path(assigns.job))
|> assign(:now, DateTime.from_unix!(assigns.os_time))
~H"""
<div
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")}
>
<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
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
"#{@state_labels[state]}: #{truncate_sec(timestamp_for_state(state, job))}"
end
defp truncate_sec(nil), do: ""
defp truncate_sec(datetime), do: DateTime.truncate(datetime, :second)
end

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,206 @@
defmodule Oban.Web.Queues.DetailInstanceComponent do
use Oban.Web, :live_component
import Oban.Web.Helpers.QueueHelper
alias Oban.Web.Components.Core
@impl Phoenix.LiveComponent
def update(%{local_limit: local_limit}, socket) do
{:ok, assign(socket, local_limit: local_limit)}
end
def update(assigns, socket) do
socket =
socket
|> assign(access: assigns.access, checks: assigns.checks)
|> 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
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<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>
<%= 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"
>
<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.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="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 class="w-5 h-5" />
<% else %>
<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)
checks = socket.assigns.checks
action = if socket.assigns.paused, do: :resume_queue, else: :pause_queue
send(self(), {action, checks["queue"], checks["name"], checks["node"]})
{: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)
limit = String.to_integer(limit)
checks = socket.assigns.checks
send(self(), {:scale_queue, checks["queue"], checks["name"], checks["node"], limit})
{:noreply, assign(socket, local_limit: limit, editing?: false)}
end
end

View file

@ -0,0 +1,254 @@
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.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-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="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">&rarr;</span>
</a>
</div>
</div>
<div
:if={Enum.empty?(@queues) and not Enum.empty?(@checks)}
class="flex items-center justify-center py-12 space-x-2 text-lg text-gray-600 dark:text-gray-300"
>
<Icons.no_symbol /> <span>No queues match the current filters.</span>
</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>
"""
end
# Components
attr :label, :string, required: true
attr :class, :string, default: ""
defp queue_header(assigns) do
~H"""
<span class={[@class, "text-xs font-medium uppercase tracking-wider py-1.5 pl-4"]}>
{@label}
</span>
"""
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"""
<li
id={"queue-#{@queue.name}"}
class="flex items-center hover:bg-gray-50 dark:hover:bg-gray-950/30"
>
<Core.row_checkbox
click="toggle-select"
value={@queue.name}
checked={@selected}
myself={@myself}
/>
<.link patch={oban_path([:queues, @queue.name])} class="py-5 flex flex-grow items-center">
<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">
<% {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>
<div class="w-20 pr-3 flex justify-center items-center space-x-1">
<Icons.pause_circle
:if={Queue.all_paused?(@queue)}
class="w-5 h-5"
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-5 h-5"
data-title="Some paused"
id={"#{@queue.name}-is-some-paused"}
phx-hook="Tippy"
rel="has-some-paused"
/>
<Icons.power
:if={Queue.terminating?(@queue)}
class="w-5 h-5"
data-title="Terminating"
id={"#{@queue.name}-is-terminating"}
phx-hook="Tippy"
rel="terminating"
/>
</div>
</div>
</.link>
</li>
"""
end
# Handlers
@impl Phoenix.LiveComponent
def handle_event("toggle-select", %{"id" => queue}, socket) do
send(self(), {:toggle_select, queue})
{:noreply, socket}
end
# Helpers
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

View file

@ -0,0 +1,119 @@
defmodule Oban.Web.RefreshComponent do
use Oban.Web, :live_component
@options [
{01, "1s"},
{02, "2s"},
{05, "5s"},
{15, "15s"},
{30, "30s"},
{60, "1m"},
{-1, "Off"}
]
@impl Phoenix.LiveComponent
def mount(socket) do
{:ok, assign(socket, :options, @options)}
end
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div
class="relative"
id="refresh-selector"
data-shortcut={JS.push("toggle-refresh", target: "#refresh-selector")}
phx-hook="Refresher"
>
<button
aria-haspopup="listbox"
aria-expanded="true"
aria-labelledby="listbox-label"
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"
phx-click={JS.toggle(to: "#refresh-menu")}
type="button"
>
<Icons.arrow_path_rounded />
<span class="ml-1 leading-6 text-sm">{:proplists.get_value(@refresh, @options)}</span>
</button>
<ul
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"
>
<%= for {value, display} <- @options do %>
<.option value={value} display={display} refresh={@refresh} />
<% end %>
</ul>
</div>
"""
end
attr :refresh, :integer, required: true
attr :value, :integer, required: true
attr :display, :string, required: true
defp option(assigns) do
class =
if assigns.refresh == assigns.value do
"text-blue-500 dark:text-blue-400"
else
"text-gray-500 dark:text-gray-400 "
end
assigns = assign(assigns, :class, class)
~H"""
<li
class={"block w-full py-1 px-2 flex items-center cursor-pointer select-none space-x-2 hover:bg-gray-50 hover:dark:bg-gray-600/30 #{@class}"}
role="option"
phx-click="select-refresh"
phx-click-away={JS.hide(to: "#refresh-menu")}
phx-target="#refresh-selector"
phx-value-interval={@value}
>
<%= if @value == @refresh do %>
<Icons.check class="w-5 h-5" />
<% else %>
<span class="block w-5 h-5"></span>
<% end %>
<span class="text-gray-800 dark:text-gray-200">{@display}</span>
</li>
"""
end
# Handlers
@impl Phoenix.LiveComponent
def handle_event("pause-refresh", _params, socket) do
send(self(), :pause_refresh)
{:noreply, socket}
end
def handle_event("resume-refresh", _params, socket) do
send(self(), :resume_refresh)
{:noreply, socket}
end
def handle_event("select-refresh", %{"interval" => interval}, socket) do
value = if is_binary(interval), do: String.to_integer(interval), else: interval
send(self(), {:update_refresh, value})
{:noreply, socket}
end
def handle_event("toggle-refresh", _params, socket) do
send(self(), :toggle_refresh)
{:noreply, socket}
end
end

View file

@ -0,0 +1,330 @@
defmodule Oban.Web.SearchComponent do
use Oban.Web, :live_component
@spinner_timeout 100
@impl Phoenix.LiveComponent
def mount(socket) do
{:ok, assign(socket, buffer: "", loading: false)}
end
@impl Phoenix.LiveComponent
def update(assigns, socket) do
suggestions =
Map.get_lazy(assigns, :suggestions, fn ->
assigns.queryable.suggest(socket.assigns.buffer, assigns.conf, resolver: assigns.resolver)
end)
socket =
socket
|> assign(assigns)
|> assign(suggestions: suggestions)
{:ok, socket}
end
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<form
class="grow relative"
id="search"
data-shortcut={JS.focus(to: "#search-input")}
phx-hook="Completer"
phx-change="change"
phx-submit="search"
phx-target={@myself}
>
<div
id="search-wrapper"
class="w-full flex items-center space-x-1.5 rounded-md shadow-inner ring-1 ring-inset ring-gray-300 dark:ring-gray-700"
>
<div class="ml-1.5 flex-none">
<%= if @loading do %>
<Icons.spinner class="w-5 h-5 text-gray-200 animate-spin dark:text-gray-600 fill-violet-500" />
<% else %>
<Icons.magnifying_glass class="w-5 h-5 text-gray-500" />
<% end %>
</div>
<div class="w-full flex flex-wrap space-x-1.5">
<.filter
:for={{param, terms} <- filterable(@params, @queryable)}
param={param}
terms={terms}
/>
<input
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-500 focus:ring-0"
id="search-input"
name="terms"
phx-blur={hide_focus()}
phx-debounce={100}
phx-focus={show_focus()}
phx-target={@myself}
placeholder="Add filters"
spellcheck="false"
type="search"
value={@buffer}
/>
</div>
</div>
<button
class={[
"absolute inset-y-0 right-0 pr-3 items-center text-gray-400 hover:text-blue-500",
unless(clearable?(@buffer, @params, @queryable), do: "hidden")
]}
data-title="Clear filters"
id="search-reset"
phx-target={@myself}
phx-click="clear"
type="reset"
>
<Icons.x_circle class="w-5 h-5" />
</button>
<nav
class="hidden absolute z-10 mt-1 w-full text-sm shadow-lg bg-white dark:bg-gray-800 focus:outline-none overflow-hidden rounded-md ring-1 ring-black/5"
id="search-suggest"
phx-click-away={JS.hide()}
>
<div class="p-2">
<.option
:for={{name, desc, exmp} <- @suggestions}
buff={@buffer}
name={name}
desc={desc}
exmp={exmp}
/>
<div :if={Enum.empty?(@suggestions)} class="w-full flex items-center space-x-2 p-1">
<Icons.exclamation_circle class="w-5 h-5 text-gray-400" />
<span class="text-gray-700">No suggestions matching <b>"{@buffer}"</b></span>
</div>
</div>
<a
href="https://oban.pro/docs/web/filtering.html"
class="w-full flex items-center space-x-1 p-2 bg-gray-100 dark:bg-gray-900 text-gray-500 dark:text-gray-400 hover:text-gray-950 dark:hover:text-gray-100"
title="Read the filtering and searching guide"
target="_blank"
>
<Icons.info_circle class="w-4 h-4" />
<span class="text-xs">Filtering Tips</span>
</a>
</nav>
</form>
"""
end
attr :param, :any, required: true
attr :terms, :any, required: true
defp filter(assigns) do
~H"""
<div class="my-1.5 flex items-center text-sm font-medium" id={"search-filter-#{@param}"}>
<span class="pl-1.5 pr-0.5 py-1 text-gray-700 dark:text-violet-950 bg-violet-100 dark:bg-violet-300 rounded-s-md whitespace-nowrap">
{format_filter(@param, @terms)}
</span>
<button
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}
phx-value-terms={@terms}
phx-target="#search"
>
<Icons.x_mark class="w-5 h-5" />
</button>
</div>
"""
end
defp format_filter(param, [path, term]) when is_list(path) do
"#{param}.#{Enum.join(path, ".")}:#{term}"
end
defp format_filter(param, term) when is_list(term) do
"#{param}:#{Enum.join(term, ",")}"
end
defp format_filter(param, term) do
"#{param}:#{term}"
end
attr :buff, :string
attr :name, :string
attr :desc, :string
attr :exmp, :string
defp option(assigns) do
~H"""
<button
class="w-full flex items-center cursor-pointer p-1 rounded-md group hover:bg-violet-500"
phx-click={JS.push("append", value: %{choice: @name})}
phx-target="#search"
type="button"
>
<span class="block px-1 py-0.5 font-medium rounded-md bg-gray-100 dark:bg-gray-900">
{highlight(@name, @buff)}
</span>
<span class="block ml-2 text-gray-600 dark:text-gray-300 group-hover:text-white">
{@desc}
</span>
<span class="block ml-auto text-right text-gray-400 dark:text-gray-500 group-hover:text-white">
{@exmp}
</span>
</button>
"""
end
defp clearable?(buffer, params, queryable) do
String.length(buffer) > 0 or map_size(filterable(params, queryable)) > 0
end
defp filterable(params, queryable), do: Map.take(params, queryable.filterable())
defp highlight(value, substr) do
match =
substr
|> String.split([":", "."], trim: true)
|> List.last()
if is_binary(value) and is_binary(match) do
pattern = Regex.compile!("(#{Regex.escape(match)})", "i")
value
|> String.replace(pattern, "<b>\\1</b>")
|> raw()
else
value
end
end
def show_focus do
%JS{}
|> JS.remove_class("ring-gray-300 dark:ring-gray-700", to: "#search-wrapper")
|> JS.add_class("shadow-blue-100 dark:shadow-blue-950", to: "#search-wrapper")
|> JS.add_class("ring-blue-500 dark:ring-blue-700", to: "#search-wrapper")
|> JS.add_class("bg-blue-100/30 dark:bg-blue-900/30", to: "#search-wrapper")
|> JS.show(to: "#search-suggest")
end
# Closing the suggest menu is done with push events to allow clicking on a suggestion to fire
# before the menu closes.
def hide_focus do
%JS{}
|> JS.add_class("ring-gray-300 dark:ring-gray-700", to: "#search-wrapper")
|> JS.remove_class("shadow-blue-100 dark:shadow-blue-950", to: "#search-wrapper")
|> JS.remove_class("ring-blue-500 dark:ring-blue-700", to: "#search-wrapper")
|> JS.remove_class("bg-blue-100/30 dark:bg-blue-900/30", to: "#search-wrapper")
end
# Events
@impl Phoenix.LiveComponent
def handle_event("change", %{"terms" => buffer}, socket) do
socket =
socket
|> assign(buffer: buffer)
|> async_suggest(buffer)
{:noreply, socket}
end
def handle_event("clear", _params, socket) do
suggestions = socket.assigns.queryable.suggest("", socket.assigns.conf)
{:noreply,
socket
|> assign(buffer: "", loading: false, suggestions: suggestions)
|> push_patch(to: oban_path(socket.assigns.page))}
end
def handle_event("append", %{"choice" => choice}, socket) do
buffer = socket.assigns.queryable.append(socket.assigns.buffer, choice)
socket
|> assign(buffer: buffer)
|> handle_submit()
end
def handle_event("complete", _params, socket) do
buffer = socket.assigns.queryable.complete(socket.assigns.buffer, socket.assigns.conf)
socket =
socket
|> assign(buffer: buffer)
|> push_event("completed", %{buffer: buffer})
|> async_suggest(buffer)
{:noreply, socket}
end
def handle_event("search", _, socket) do
handle_submit(socket)
end
def handle_event("remove-filter", %{"param" => param, "terms" => _}, socket) do
params = Map.delete(socket.assigns.params, String.to_existing_atom(param))
{:noreply, push_patch(socket, to: oban_path(socket.assigns.page, params))}
end
defp handle_submit(socket) do
%{buffer: buffer, queryable: queryable} = socket.assigns
if String.ends_with?(buffer, ":") or String.ends_with?(buffer, ".") do
{:noreply, async_suggest(socket, buffer)}
else
parsed = queryable.parse(buffer)
params = Map.merge(socket.assigns.params, parsed, fn _key, old, new -> old ++ new end)
suggestions = queryable.suggest("", socket.assigns.conf)
{:noreply,
socket
|> assign(buffer: "", loading: false, suggestions: suggestions)
|> push_patch(to: oban_path(socket.assigns.page, params))}
end
end
def handle_info({:DOWN, _ref, :process, _pid, _reason}, socket) do
{:noreply, socket}
end
def handle_info({ref, suggestions}, socket) when is_reference(ref) do
{:noreply, assign(socket, loading: false, suggestions: suggestions)}
end
defp async_suggest(socket, buffer) do
self = self()
fun = fn ->
suggestions =
socket.assigns.queryable.suggest(buffer, socket.assigns.conf,
resolver: socket.assigns.resolver
)
assigns =
socket.assigns
|> Map.take(~w(id conf params queryable resolver)a)
|> Map.put(:loading, false)
|> Map.put(:suggestions, suggestions)
send_update(self, __MODULE__, assigns)
end
fun
|> Task.async()
|> Task.yield(@spinner_timeout)
|> case do
nil -> assign(socket, loading: true)
_ok -> socket
end
end
end

View file

@ -0,0 +1,114 @@
defmodule Oban.Web.ShortcutsComponent do
use Oban.Web, :live_component
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div
id="shortcuts"
class="relative z-50 hidden"
data-shortcut={show_modal()}
phx-hook="Shortcuts"
phx-remove={hide_modal()}
phx-target={@myself}
>
<div
id="shortcuts-bg"
class="bg-zinc-50/80 dark:bg-zinc-950/80 fixed inset-0 transition-opacity"
aria-hidden="true"
/>
<div class="fixed inset-0 overflow-y-auto" role="dialog" aria-modal="true">
<div class="flex min-h-full items-center justify-center">
<div
class="hidden w-full max-w-lg p-2 sm:p-4 relative rounded-md bg-white dark:bg-gray-950 shadow-lg ring-1 ring-zinc-700/10 transition"
id="shortcuts-container"
phx-click-away={hide_modal()}
phx-key="escape"
phx-window-keydown={hide_modal()}
>
<button
phx-click={hide_modal()}
type="button"
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
aria-label="close"
>
<Icons.x_mark />
</button>
<div>
<h3 class="font-semibold dark:text-gray-200 mb-4">Keyboard Shortcuts</h3>
<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" />
<.list_item description="Cycle themes" shortcut="t" />
<.list_item description="Open this modal" shortcut="?" />
</dl>
</div>
</div>
</div>
</div>
</div>
"""
end
attr :description, :string, required: true
attr :shortcut, :string, required: true
defp list_item(assigns) do
~H"""
<div class="py-4 flex justify-between">
<dt class="text-gray-700 dark:text-gray-300 font-medium">{@description}</dt>
<dd class="text-gray-700 dark:text-gray-300">
<kbd class="px-2 py-1 bg-gray-50 dark:bg-gray-900 ring-1 ring-zinc-300 rounded-md">
{@shortcut}
</kbd>
</dd>
</div>
"""
end
# JS
defp show_modal do
%JS{}
|> JS.show(to: "#shortcuts")
|> JS.show(
to: "#shortcuts-bg",
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
)
|> JS.show(
to: "#shortcuts-container",
transition:
{"transition-all transform ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
|> JS.add_class("overflow-hidden", to: "body")
end
defp hide_modal do
%JS{}
|> JS.hide(
to: "#shortcuts-bg",
transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
)
|> JS.hide(
to: "#shortcuts-container",
time: 200,
transition:
{"transition-all transform ease-in duration-200",
"opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
|> JS.hide(to: "#shortcuts", transition: {"block", "block", "hidden"})
|> JS.remove_class("overflow-hidden", to: "body")
|> JS.pop_focus()
end
end

View file

@ -0,0 +1,102 @@
defmodule Oban.Web.ThemeComponent do
use Oban.Web, :live_component
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div
class="relative"
id="theme-selector"
data-shortcut={JS.push("cycle-theme", target: "#theme-selector")}
phx-hook="Themer"
>
<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="Change theme"
id="theme-menu-toggle"
phx-hook="Tippy"
phx-click={JS.toggle(to: "#theme-menu")}
type="button"
>
<.theme_icon theme={@theme} />
</button>
<ul
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"
>
<.option :for={theme <- ~w(light dark system)} myself={@myself} theme={@theme} value={theme} />
</ul>
</div>
"""
end
attr :myself, :any, required: true
attr :theme, :string, required: true
attr :value, :string, required: true
defp option(assigns) do
class =
if assigns.theme == assigns.value do
"text-blue-500 dark:text-blue-400"
else
"text-gray-500 dark:text-gray-400 "
end
assigns = assign(assigns, :class, class)
~H"""
<li
class={"block w-full py-1 px-2 flex items-center cursor-pointer space-x-2 hover:bg-gray-50 hover:dark:bg-gray-600/30 #{@class}"}
id={"select-theme-#{@value}"}
phx-click-away={JS.hide(to: "#theme-menu")}
phx-click="update-theme"
phx-target={@myself}
phx-value-theme={@value}
role="option"
>
<.theme_icon theme={@value} />
<span class="capitalize text-gray-800 dark:text-gray-200">{@value}</span>
</li>
"""
end
attr :theme, :string, required: true
defp theme_icon(assigns) do
~H"""
<%= case @theme do %>
<% "light" -> %>
<Icons.sun />
<% "dark" -> %>
<Icons.moon />
<% "system" -> %>
<Icons.computer_desktop />
<% end %>
"""
end
@impl Phoenix.LiveComponent
def handle_event("update-theme", %{"theme" => theme}, socket) do
send(self(), {:update_theme, theme})
{:noreply, socket}
end
def handle_event("cycle-theme", _params, socket) do
theme =
case socket.assigns.theme do
"light" -> "dark"
"dark" -> "system"
"system" -> "light"
end
send(self(), {:update_theme, theme})
{:noreply, socket}
end
end

View file

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

View file

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

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

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

26
vendor/oban_web/lib/oban/web/page.ex vendored Normal file
View file

@ -0,0 +1,26 @@
defmodule Oban.Web.Page do
@moduledoc false
alias Phoenix.LiveView.Socket
@doc """
Called from parent live view on mount and before page changes.
"""
@callback handle_mount(socket :: Socket.t()) :: Socket.t()
@doc """
Called by parent live view on mount, and on each subsequent refresh.
"""
@callback handle_refresh(socket :: Socket.t()) :: Socket.t()
@doc """
Called by parent live view on param changes.
"""
@callback handle_params(params :: map(), uri :: String.t(), socket :: Socket.t()) ::
{:noreply, Socket.t()}
@doc """
Called by parent live view on info messages.
"""
@callback handle_info(message :: term(), socket :: Socket.t()) :: {:noreply, Socket.t()}
end

View file

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

View file

@ -0,0 +1,602 @@
defmodule Oban.Web.JobsPage do
@behaviour Oban.Web.Page
use Oban.Web, :live_component
alias Oban.Met
alias Oban.Web.{JobQuery, Metrics, Page, QueueQuery, SearchComponent, SortComponent, Telemetry}
alias Oban.Web.Jobs.{ChartComponent, DetailComponent, NewComponent}
alias Oban.Web.Jobs.{SidebarComponent, TableComponent}
@known_params ~w(args ids limit meta nodes priorities queues sort_by sort_dir state tags workers)
@ordered_states ~w(executing available scheduled suspended retryable cancelled discarded completed)
@impl Phoenix.LiveComponent
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:show_new_form, fn -> false end)
{:ok, socket}
end
@impl Phoenix.LiveComponent
def render(assigns) 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}
states={@states}
width={@sidebar_width}
csp_nonces={@csp_nonces}
/>
<div class="flex-grow">
<.live_component
:if={is_nil(@detailed)}
id="chart"
conf={@conf}
init_state={@init_state}
module={ChartComponent}
os_time={@os_time}
params={@params}
/>
<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 %>
<div class="flex items-start pr-3 py-3 border-b border-gray-200 dark:border-gray-700">
<div id="jobs-header" class="h-10 pr-12 flex-none flex items-center">
<Core.all_checkbox
click="toggle-select-all"
checked={checked_mode(@jobs, @selected)}
myself={@myself}
/>
<h2 class="text-base font-semibold dark:text-gray-200">Jobs</h2>
</div>
<div
:if={Enum.any?(@selected)}
id="bulk-actions"
class="pt-1 flex items-center space-x-3"
>
<Core.action_button
:if={cancelable?(@jobs, @access)}
label="Cancel"
click="cancel-jobs"
target={@myself}
>
<:icon><Icons.x_circle class="w-5 h-5" /></:icon>
<:title>Cancel Jobs</:title>
</Core.action_button>
<Core.action_button
:if={retryable?(@jobs, @access)}
label="Retry"
click="retry-jobs"
target={@myself}
>
<:icon><Icons.arrow_right_circle class="w-5 h-5" /></:icon>
<:title>Retry Jobs</:title>
</Core.action_button>
<Core.action_button
:if={runnable?(@jobs, @access)}
label="Run Now"
click="retry-jobs"
target={@myself}
>
<:icon><Icons.arrow_right_circle class="w-5 h-5" /></:icon>
<:title>Run Jobs Now</:title>
</Core.action_button>
<Core.action_button
:if={deletable?(@jobs, @access)}
label="Delete"
click="delete-jobs"
target={@myself}
danger={true}
>
<:icon><Icons.trash class="w-5 h-5" /></:icon>
<:title>Delete Jobs</:title>
</Core.action_button>
</div>
<.live_component
:if={Enum.empty?(@selected)}
conf={@conf}
id="search"
module={SearchComponent}
page={:jobs}
params={without_defaults(@params, @default_params)}
queryable={JobQuery}
resolver={@resolver}
/>
<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>
<SortComponent.select
:if={Enum.empty?(@selected)}
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>
<.live_component
id="jobs-table"
conf={@conf}
jobs={@jobs}
module={TableComponent}
params={@params}
resolver={@resolver}
selected={@selected}
/>
<% end %>
</div>
</div>
<.live_component
:if={@show_new_form}
id="new-job-form"
access={@access}
conf={@conf}
module={NewComponent}
queues={@queues}
/>
</div>
"""
end
@keep_on_mount ~w(default_params detailed jobs nodes params queues selected states)a
@impl Page
def handle_mount(socket) do
default = fn ->
%{limit: 20, sort_by: "time", sort_dir: "asc", state: "executing"}
end
assigns = Map.drop(socket.assigns, @keep_on_mount)
%{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)
|> assign_new(:params, default)
|> assign_new(:queues, fn -> [] end)
|> assign_new(:selected, &MapSet.new/0)
|> assign_new(:states, fn -> [] end)
end
@impl Page
def handle_refresh(socket) do
%{conf: conf, params: params, resolver: resolver} = socket.assigns
jobs = JobQuery.all_jobs(params, conf, resolver: resolver)
selected =
if Enum.any?(socket.assigns.selected) do
all_job_ids = JobQuery.all_job_ids(params, conf, resolver: resolver)
all_job_ids
|> MapSet.new()
|> MapSet.intersection(socket.assigns.selected)
else
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: detailed,
diagnostics: diagnostics,
diagnostics_at: diagnostics_at,
history: history,
jobs: jobs,
nodes: nodes(conf),
os_time: System.os_time(:second),
queues: queues(conf, socket.assigns.queues),
selected: selected,
states: states(conf, socket.assigns.states)
)
end
@impl Page
def handle_params(%{"id" => "new"} = params, _uri, socket) do
params = params_with_defaults(params, socket)
{: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, show_new_form: false, page_title: page_title(job))
|> assign(diagnostics: nil, diagnostics_at: nil)
|> assign(history: history)
|> assign(params: params)}
end
end
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, 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))
|> assign(
queues: queues(conf, socket.assigns.queues),
states: states(conf, socket.assigns.states)
)
{:noreply, socket}
end
@impl Phoenix.LiveComponent
def handle_event("toggle-select-all", _params, socket) do
send(self(), :toggle_select_all)
{:noreply, socket}
end
def handle_event("cancel-jobs", _params, socket) do
if can?(:cancel_jobs, socket.assigns.access) do
send(self(), :cancel_selected)
end
{:noreply, assign(socket, expanded?: false)}
end
def handle_event("retry-jobs", _params, socket) do
if can?(:retry_jobs, socket.assigns.access) do
send(self(), :retry_selected)
end
{:noreply, assign(socket, expanded?: false)}
end
def handle_event("delete-jobs", _params, socket) do
if can?(:delete_jobs, socket.assigns.access) do
send(self(), :delete_selected)
end
{:noreply, assign(socket, expanded?: false)}
end
# Queues
@impl Page
def handle_info({ref, _val}, socket) when is_reference(ref) do
{:noreply, socket}
end
def handle_info({:DOWN, _ref, :process, _pid, :normal}, socket) do
{:noreply, socket}
end
def handle_info({:scale_queue, queue, limit}, socket) do
Telemetry.action(:scale_queue, socket, [queue: queue, limit: limit], fn ->
Oban.scale_queue(socket.assigns.conf.name, queue: queue, limit: limit)
end)
{: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
params =
socket.assigns.params
|> Map.update!(:limit, &to_string(&1 + inc))
|> without_defaults(socket.assigns.default_params)
{:noreply, push_patch(socket, to: oban_path(:jobs, params), replace: true)}
end
# Single Actions
def handle_info({:cancel_job, job}, socket) do
Telemetry.action(:cancel_jobs, socket, [job_ids: [job.id]], fn ->
Oban.cancel_job(socket.assigns.conf.name, job.id)
end)
job = %{job | state: "cancelled", cancelled_at: DateTime.utc_now()}
{:noreply, assign(socket, detailed: job)}
end
def handle_info({:delete_job, job}, socket) do
Telemetry.action(:delete_jobs, socket, [job_ids: [job.id]], fn ->
JobQuery.delete_jobs(socket.assigns.conf, [job.id])
end)
{:noreply, push_patch(socket, to: oban_path(:jobs), replace: true)}
end
def handle_info({:retry_job, job}, socket) do
Telemetry.action(:retry_jobs, socket, [job_ids: [job.id]], fn ->
JobQuery.retry_jobs(socket.assigns.conf, [job.id])
end)
job = %{job | state: "available", completed_at: nil, discarded_at: nil}
{: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
selected = socket.assigns.selected
selected =
if MapSet.member?(selected, job_id) do
MapSet.delete(selected, job_id)
else
MapSet.put(selected, job_id)
end
{:noreply, assign(socket, selected: selected)}
end
def handle_info(:toggle_select_all, socket) do
selected =
if Enum.any?(socket.assigns.selected) do
MapSet.new()
else
# Always include the jobs we can see currently to compensate for slower refresh rates.
# Without this, visible jobs may not be selected and the interface looks broken.
local_set = MapSet.new(socket.assigns.jobs, & &1.id)
socket.assigns.params
|> JobQuery.all_job_ids(socket.assigns.conf)
|> MapSet.new()
|> MapSet.union(local_set)
end
{:noreply, assign(socket, selected: selected)}
end
def handle_info(:cancel_selected, socket) do
job_ids = MapSet.to_list(socket.assigns.selected)
Telemetry.action(:cancel_jobs, socket, [job_ids: job_ids], fn ->
JobQuery.cancel_jobs(socket.assigns.conf, job_ids)
end)
socket =
socket
|> hide_and_clear_selected()
|> put_flash_with_clear(:info, "Selected jobs canceled")
{:noreply, handle_refresh(socket)}
end
def handle_info(:retry_selected, socket) do
job_ids = MapSet.to_list(socket.assigns.selected)
Telemetry.action(:retry_jobs, socket, [job_ids: job_ids], fn ->
JobQuery.retry_jobs(socket.assigns.conf, job_ids)
end)
socket =
socket
|> hide_and_clear_selected()
|> put_flash_with_clear(:info, "Selected jobs scheduled to run immediately")
{:noreply, handle_refresh(socket)}
end
def handle_info(:delete_selected, socket) do
job_ids = MapSet.to_list(socket.assigns.selected)
Telemetry.action(:delete_jobs, socket, [job_ids: job_ids], fn ->
JobQuery.delete_jobs(socket.assigns.conf, job_ids)
end)
socket =
socket
|> hide_and_clear_selected()
|> put_flash_with_clear(:info, "Selected jobs deleted")
{:noreply, handle_refresh(socket)}
end
# Param Helpers
defp params_with_defaults(params, socket) do
params =
params
|> Map.take(@known_params)
|> decode_params()
Map.merge(socket.assigns.default_params, params)
end
# Socket Helpers
defp hide_and_clear_selected(socket) do
%{jobs: jobs, selected: selected} = socket.assigns
jobs = for job <- jobs, do: Map.put(job, :hidden?, MapSet.member?(selected, job.id))
assign(socket, jobs: jobs, selected: MapSet.new())
end
# State Helpers
defp checked_mode(jobs, selected) do
cond do
Enum.empty?(selected) -> :none
Enum.all?(jobs, &MapSet.member?(selected, &1.id)) -> :all
true -> :some
end
end
defp cancelable?(jobs, access) do
can?(:cancel_jobs, access) and Enum.any?(jobs, &cancelable?/1)
end
defp runnable?(jobs, access) do
can?(:retry_jobs, access) and Enum.any?(jobs, &runnable?/1)
end
defp retryable?(jobs, access) do
can?(:retry_jobs, access) and Enum.any?(jobs, &retryable?/1)
end
defp deletable?(jobs, access) do
can?(:delete_jobs, access) and Enum.any?(jobs, &deletable?/1)
end
# Metrics Helpers
def nodes(conf) do
conf.name
|> Met.checks()
|> Enum.reduce(%{}, fn check, acc ->
node = check["node"]
count = length(check["running"])
limit = check["local_limit"] || check["limit"]
acc
|> Map.put_new(node, %{name: node, count: 0, limit: 0})
|> update_in([node, :count], &(&1 + count))
|> update_in([node, :limit], &(&1 + limit))
end)
|> Map.values()
|> Enum.sort_by(& &1.name)
end
defp states(conf, previous) do
Metrics.state_counts(conf.name, @ordered_states, previous)
end
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

View file

@ -0,0 +1,510 @@
defmodule Oban.Web.QueuesPage do
@behaviour Oban.Web.Page
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}
@inc_limit 20
@max_limit 100
@min_limit 20
@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 my-6">
<div>
<div class="bg-white dark:bg-gray-900 rounded-md shadow-lg overflow-hidden">
<%= if @detail do %>
<.live_component
id="detail"
access={@access}
conf={@conf}
checks={@checks}
module={DetailComponent}
node_history={@node_history}
queue={@detail}
/>
<% else %>
<div
id="queues-header"
class="pr-3 flex items-center border-b border-gray-200 dark:border-gray-700"
>
<div class="flex-none flex items-center pr-12">
<Core.all_checkbox
click="toggle-select-all"
checked={select_mode(@checks, @selected)}
myself={@myself}
/>
<h2 class="text-lg dark:text-gray-200 leading-4 font-bold">Queues</h2>
</div>
<div
:if={Enum.any?(@selected)}
id="bulk-actions"
class="ml-6 flex items-center space-x-3"
>
<Core.action_button
:if={can?(:pause_queues, @access)}
label="Pause"
click="pause-queues"
target={@myself}
>
<:icon><Icons.pause_circle class="w-5 h-5" /></:icon>
<:title>Pause Queues</:title>
</Core.action_button>
<Core.action_button
:if={can?(:pause_queues, @access)}
label="Resume"
click="resume-queues"
target={@myself}
>
<:icon><Icons.play_circle class="w-5 h-5" /></:icon>
<:title>Resume Queues</:title>
</Core.action_button>
<Core.action_button
:if={can?(:stop_queues, @access)}
label="Stop"
click="stop-queues"
target={@myself}
danger={true}
>
<:icon><Icons.x_circle class="w-5 h-5" /></:icon>
<:title>Stop Queues</:title>
</Core.action_button>
</div>
<.live_component
:if={Enum.empty?(@selected)}
conf={@conf}
id="search"
module={SearchComponent}
page={:queues}
params={without_defaults(@params, @default_params)}
queryable={QueueQuery}
resolver={@resolver}
/>
<div class="pl-3 ml-auto">
<span
:if={Enum.any?(@selected)}
id="selected-count"
class="block text-sm font-semibold mr-3"
>
{MapSet.size(@selected)} Selected
</span>
<SortComponent.select
:if={Enum.empty?(@selected)}
id="queues-sort"
by={~w(name nodes avail exec local global rate_limit started)}
page={:queues}
params={sort_params(@params, @default_params)}
/>
</div>
</div>
<.live_component
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>
</div>
"""
end
@impl Page
def handle_mount(socket) do
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 -> %{} 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
params = socket.assigns.params
limit = params[:limit] || @min_limit
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 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
total = checks |> Enum.uniq_by(&Map.get(&1, "queue")) |> Enum.count()
cond do
Enum.any?(selected) and Enum.count(selected) == total -> :all
Enum.any?(selected) -> :some
true -> :none
end
end
# Handlers
@impl Page
def handle_params(%{"id" => queue}, _uri, socket) do
title = "#{String.capitalize(queue)} Queue"
if Enum.any?(socket.assigns.checks, &(&1["queue"] == queue)) do
{:noreply, assign(socket, detail: queue, page_title: page_title(title))}
else
{:noreply, push_patch(socket, to: oban_path(:queues), replace: true)}
end
end
def handle_params(params, _uri, socket) do
params =
params
|> Map.take(@known_params)
|> decode_params()
socket =
socket
|> assign(page_title: page_title("Queues"))
|> assign(detail: nil, params: Map.merge(socket.assigns.default_params, params))
|> handle_refresh()
{:noreply, socket}
end
@impl Phoenix.LiveComponent
def handle_event("pause-queues", _params, socket) do
send(self(), :pause_queues)
{:noreply, socket}
end
def handle_event("resume-queues", _params, socket) do
send(self(), :resume_queues)
{:noreply, socket}
end
def handle_event("stop-queues", _params, socket) do
send(self(), :stop_queues)
{:noreply, socket}
end
def handle_event("toggle-select-all", _params, socket) do
send(self(), :toggle_select_all)
{: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 ->
Oban.pause_queue(socket.assigns.conf.name, queue: queue)
end)
{:noreply, socket}
end
def handle_info({:pause_queue, queue, name, node}, socket) do
Telemetry.action(:pause_queue, socket, [queue: queue, name: name, node: node], fn ->
Oban.pause_queue(socket.assigns.conf.name, node: node, queue: queue)
end)
{:noreply, socket}
end
def handle_info({:resume_queue, queue}, socket) do
Telemetry.action(:resume_queue, socket, [queue: queue], fn ->
Oban.resume_queue(socket.assigns.conf.name, queue: queue)
end)
{:noreply, socket}
end
def handle_info({:resume_queue, queue, name, node}, socket) do
Telemetry.action(:resume_queue, socket, [queue: queue, name: name, node: node], fn ->
Oban.resume_queue(socket.assigns.conf.name, node: node, queue: queue)
end)
{:noreply, socket}
end
def handle_info(:pause_queues, socket) do
enforce_access!(:pause_queues, socket.assigns.access)
queues = socket.assigns.selected
Telemetry.action(:pause_queues, socket, [queues: queues], fn ->
Enum.each(queues, &Oban.pause_queue(socket.assigns.conf.name, queue: &1))
end)
socket =
socket
|> assign(:selected, MapSet.new())
|> put_flash_with_clear(:info, "Selected queues paused")
{:noreply, socket}
end
def handle_info(:resume_queues, socket) do
enforce_access!(:pause_queues, socket.assigns.access)
queues = socket.assigns.selected
Telemetry.action(:resume_queues, socket, [queues: queues], fn ->
Enum.each(queues, &Oban.resume_queue(socket.assigns.conf.name, queue: &1))
end)
socket =
socket
|> assign(:selected, MapSet.new())
|> put_flash_with_clear(:info, "Selected queues resumed")
{:noreply, socket}
end
def handle_info(:stop_queues, socket) do
enforce_access!(:stop_queues, socket.assigns.access)
queues = socket.assigns.selected
Telemetry.action(:stop_queues, socket, [queues: queues], fn ->
Enum.each(queues, &Oban.stop_queue(socket.assigns.conf.name, queue: &1))
end)
socket =
socket
|> assign(:selected, MapSet.new())
|> put_flash_with_clear(:info, "Selected queues stopped")
{: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
selected =
if MapSet.member?(selected, queue) do
MapSet.delete(selected, queue)
else
MapSet.put(selected, queue)
end
{:noreply, assign(socket, selected: selected)}
end
def handle_info(:toggle_select_all, socket) do
selected =
if Enum.any?(socket.assigns.selected) do
MapSet.new()
else
socket.assigns.params
|> QueueQuery.all_queues(socket.assigns.conf)
|> MapSet.new(& &1.name)
end
{:noreply, assign(socket, selected: selected)}
end
def handle_info({:scale_queue, queue, name, node, limit}, socket) do
meta = [queue: queue, name: name, node: node, limit: limit]
Telemetry.action(:scale_queue, socket, meta, fn ->
Oban.scale_queue(socket.assigns.conf.name, node: node, queue: queue, limit: limit)
end)
send_update(DetailComponent, id: "detail", local_limit: limit)
{:noreply,
put_flash_with_clear(socket, :info, "Local limit set for #{queue} queue on #{node}")}
end
def handle_info({:scale_queue, queue, opts}, socket) do
opts = Keyword.put(opts, :queue, queue)
Telemetry.action(:scale_queue, socket, opts, fn ->
Oban.scale_queue(socket.assigns.conf.name, opts)
end)
if Keyword.has_key?(opts, :limit) do
for checks <- socket.assigns.checks do
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
# Socket Helpers
defp scale_message(queue, opts) do
cond do
Keyword.has_key?(opts, :global_limit) and is_nil(opts[:global_limit]) ->
"Global limit disabled for #{queue} queue"
Keyword.has_key?(opts, :global_limit) ->
"Global limit set for #{queue} queue"
Keyword.has_key?(opts, :rate_limit) and is_nil(opts[:rate_limit]) ->
"Rate limit disabled for #{queue} queue"
Keyword.has_key?(opts, :rate_limit) ->
"Rate limit set for #{queue} queue"
Keyword.has_key?(opts, :limit) ->
"Local limit set for #{queue} queue"
end
end
end

View file

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

View file

@ -0,0 +1,20 @@
defmodule Oban.Web.Plugins.Stats do
@moduledoc false
@behaviour Oban.Plugin
use GenServer
@impl Oban.Plugin
def start_link(opts) do
IO.warn("Oban.Web.Plugins.Stats is no longer needed, remove it from your plugins")
GenServer.start_link(__MODULE__, opts)
end
@impl GenServer
def init(_opts), do: :ignore
@impl Oban.Plugin
def validate(_opts), do: :ok
end

View file

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

View file

@ -0,0 +1,710 @@
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}
@defaults %{
limit: 30,
sort_by: "time",
sort_dir: "asc",
state: "executing"
}
@list_fields [
:id,
:args,
:attempt,
:attempted_by,
:worker,
:queue,
:max_attempts,
:meta,
:state,
:inserted_at,
:attempted_at,
:cancelled_at,
:completed_at,
:discarded_at,
:scheduled_at
]
@refresh_fields [
:attempt,
:errors,
:meta,
:state,
:attempted_at,
:cancelled_at,
:completed_at,
:discarded_at,
:scheduled_at
]
@states Map.new(Oban.Job.states(), &{to_string(&1), &1})
@history_limit 60
defmacrop json_table(field) do
quote do
fragment("json_table(?, '$[*]' COLUMNS (value TEXT PATH '$'))", unquote(field))
end
end
defmacrop json_unnest(field) do
quote do
fragment("json_array_elements_text(array_to_json(?))", unquote(field))
end
end
defmacrop mysql_kv_table(field) do
quote do
fragment(
"""
(SELECT jt.name AS `key`,
json_extract(?, CONCAT('$.', jt.name)) AS `value`
FROM json_table(json_keys(?), '$[*]' COLUMNS (name TEXT PATH '$')) AS jt)
""",
unquote(field),
unquote(field)
)
end
end
defmacrop mysql_path_key(key, value) do
quote do
fragment(
"CONCAT(?, (CASE json_type(?) WHEN 'OBJECT' THEN '.' ELSE ':' END))",
unquote(key),
unquote(value)
)
end
end
defmacrop postgres_path_key(key, value) do
quote do
fragment(
"? || (CASE jsonb_typeof(?) WHEN 'object' THEN '.' ELSE ':' END)",
unquote(key),
unquote(value)
)
end
end
defmacrop sqlite_path_key(key, value) do
quote do
fragment(
"? || (CASE json_type(json_quote(?)) WHEN 'object' THEN '.' ELSE ':' END)",
unquote(key),
unquote(value)
)
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.
defmacrop subtract_unsigned(id, limit) do
quote do
fragment(
"CASE WHEN ? > ? THEN ? - ? ELSE 1 END",
unquote(id),
unquote(limit),
unquote(id),
unquote(limit)
)
end
end
def parse(terms) when is_binary(terms) do
Search.parse(terms, &parse_term/1)
end
@suggest_qualifier [
{"args.", "a key or value in args", "args.id:123"},
{"ids:", "one or more job ids", "ids:1,2,3"},
{"meta.", "a key or value in meta", "meta.batch_id:123"},
{"nodes:", "host name", "nodes:machine@somehost"},
{"priorities:", "number from 0 to 9", "priorities:1"},
{"queues:", "queue name", "queues:default"},
{"tags:", "tag name", "tags:super,duper"},
{"workers:", "worker module", "workers:MyApp.SomeWorker"}
]
@suggest_priority [
{"0", "critical", "priorities:0"},
{"1", "urgent", "priorities:1"},
{"2", "high", "priorities:2"},
{"3", "medium-high", "priorities:3"},
{"4", "medium", "priorities:4"},
{"5", "medium-low", "priorities:5"},
{"6", "low", "priorities:6"},
{"7", "very-low", "priorities:7"},
{"8", "minimal", "priorities:8"},
{"9", "negligible", "priorities:9"}
]
@known_qualifiers for {qualifier, _, _} <- @suggest_qualifier, into: MapSet.new(), do: qualifier
def filterable, do: ~w(args ids meta nodes priorities queues tags workers)a
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
["args." <> path, frag] -> suggest_json_vals(:args, path, frag, conf, opts)
["meta." <> path, frag] -> suggest_json_vals(:meta, path, frag, conf, opts)
["nodes", frag] -> suggest_nodes(frag, conf, opts)
["queues", frag] -> suggest_queues(frag, conf, opts)
["priorities", frag] -> suggest_static(@suggest_priority, frag)
["tags", frag] -> suggest_tags(frag, conf, opts)
["workers", frag] -> suggest_workers(frag, conf, opts)
["args." <> path] -> suggest_json_path(:args, path, conf, opts)
["meta." <> path] -> suggest_json_path(:meta, path, conf, opts)
[frag] -> suggest_static(@suggest_qualifier, frag)
_ -> []
end
end
end
defp suggest_static(possibilities, fragment) do
for {field, _, _} = suggest <- possibilities,
String.starts_with?(field, fragment),
do: suggest
end
defp suggest_json_path(field, term, conf, opts) do
{frag, path} =
term
|> String.split(".")
|> then(&List.pop_at(&1, length(&1) - 1))
json_path = json_path(path)
query = hint_limit_query(field, opts, conf)
subquery =
cond do
Enum.empty?(path) ->
select(query, [j], %{value: field(j, ^field)})
is_mysql(conf) ->
query
|> select([j], %{value: fragment("json_extract(?, ?)", field(j, ^field), ^json_path)})
|> where([j], mysql_extract_type(field(j, ^field), ^json_path) == "object")
is_sqlite(conf) ->
query
|> select([j], %{value: fragment("?->?", field(j, ^field), ^json_path)})
|> where([j], sqlite_extract_type(field(j, ^field), ^json_path) == "object")
true ->
query
|> select([j], %{value: fragment("?#>?", field(j, ^field), ^path)})
|> where([j], postgres_extract_type(field(j, ^field), ^path) == "object")
end
query =
cond do
is_mysql(conf) ->
subquery
|> subquery()
|> join(:inner_lateral, [o], x in mysql_kv_table(o.value), on: true)
|> select([_, x], mysql_path_key(x.key, x.value))
|> distinct(true)
is_sqlite(conf) ->
subquery
|> subquery()
|> join(:inner, [o], x in fragment("json_each(?)", o.value), on: true)
|> select([_, x], sqlite_path_key(x.key, x.value))
|> distinct(true)
true ->
subquery
|> subquery()
|> join(:inner_lateral, [o], x in fragment("jsonb_each(?)", o.value), on: true)
|> select([_, x], postgres_path_key(x.key, x.value))
|> distinct(true)
end
{field, :keys, path}
|> cache_query(query, conf)
|> Kernel.--(["return:"])
|> Search.restrict_suggestions(frag)
end
defp suggest_json_vals(_field, "", _frag, _conf, _opts), do: []
defp suggest_json_vals(field, path, frag, conf, opts) do
json_path = json_path(path)
scalars = ~w(boolean double integer number real string text)
query =
field
|> hint_limit_query(opts, conf)
|> distinct(true)
query =
cond do
is_mysql(conf) ->
query
|> select([j], fragment("json_extract(?, ?)", field(j, ^field), ^json_path))
|> where([j], mysql_extract_type(field(j, ^field), ^json_path) in ^scalars)
is_sqlite(conf) ->
query
|> select([j], fragment("json_extract(?, ?)", field(j, ^field), ^json_path))
|> where([j], sqlite_extract_type(field(j, ^field), ^json_path) in ^scalars)
true ->
path = String.split(path, ".")
query
|> select([j], fragment("?#>?", field(j, ^field), ^path))
|> where([j], postgres_extract_type(field(j, ^field), ^path) in ^scalars)
end
{field, :vals, path}
|> cache_query(query, conf)
|> Enum.map(&String.slice(to_string(&1), 0..90))
|> Search.restrict_suggestions(frag)
end
defp suggest_nodes(frag, conf, opts) do
query =
:nodes
|> hint_limit_query(opts, conf)
|> distinct(true)
|> where([j], j.state not in ~w(available scheduled))
query =
if is_sqlite(conf) or is_mysql(conf) do
select(query, [j], fragment("?->>'$[0]'", j.attempted_by))
else
select(query, [j], fragment("?[1]", j.attempted_by))
end
:nodes
|> cache_query(query, conf)
|> Search.restrict_suggestions(frag)
end
defp suggest_queues(frag, conf, opts) do
query =
:queues
|> hint_limit_query(opts, conf)
|> select([j], j.queue)
|> distinct(true)
:queues
|> cache_query(query, conf)
|> Search.restrict_suggestions(frag)
end
defp suggest_tags(frag, conf, opts) do
query = hint_limit_query(:tags, opts, conf)
query =
cond do
is_sqlite(conf) ->
join(query, :inner, [j], x in fragment("json_each(?)", j.tags), on: true)
is_mysql(conf) ->
join(query, :inner, [j], x in json_table(j.tags), on: true)
true ->
join(query, :inner, [j], x in json_unnest(j.tags), on: true)
end
query =
query
|> select([_, x], x.value)
|> distinct(true)
:tags
|> cache_query(query, conf)
|> Search.restrict_suggestions(frag)
end
defp suggest_workers(frag, conf, opts) do
query =
:workers
|> hint_limit_query(opts, conf)
|> select([j], j.worker)
|> distinct(true)
:workers
|> cache_query(query, conf)
|> Search.restrict_suggestions(frag)
end
defp cache_query(key, query, conf) do
Cache.fetch(key, fn -> Repo.all(conf, query) end)
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
# Queries
def all_jobs(params, conf, opts \\ []) do
params = params_with_defaults(params)
conditions = Enum.reduce(params, true, &filter(&1, &2, conf))
query =
params.state
|> jobs_limit_query(opts, conf)
|> select(^@list_fields)
|> where(^conditions)
|> order(params.sort_by, params.state, params.sort_dir)
|> limit(^params.limit)
Repo.all(conf, query)
end
def all_job_ids(params, conf, opts \\ []) do
params = params_with_defaults(params)
conditions = Enum.reduce(params, true, &filter(&1, &2, conf))
limit = bulk_action_limit(params.state, opts)
query =
params.state
|> jobs_limit_query(opts, conf)
|> select([j], j.id)
|> where(^conditions)
|> order(params.sort_by, params.state, params.sort_dir)
|> limit(^limit)
Repo.all(conf, query)
end
defp params_with_defaults(params) do
@defaults
|> Map.merge(params)
|> Map.update!(:sort_by, &maybe_atomize/1)
|> Map.update!(:sort_dir, &maybe_atomize/1)
end
defp jobs_limit_query(state, opts, conf) do
@states
|> Map.fetch!(state)
|> limit_query(:jobs_query_limit, opts, conf)
end
defp hint_limit_query(qual, opts, conf) do
limit_query(qual, :hint_query_limit, opts, conf)
end
defp bulk_action_limit(state, opts) do
Resolver.call_with_fallback(opts[:resolver], :bulk_action_limit, [state])
end
defp limit_query(value, fun, opts, conf) do
case Resolver.call_with_fallback(opts[:resolver], fun, [value]) do
:infinity ->
Job
limit ->
last_id =
Job
|> select([j], type(subtract_unsigned(j.id, ^limit), :integer))
|> order_by(desc: :id)
|> limit(1)
|> then(&Repo.one(conf, &1))
where(Job, [j], j.id >= ^(last_id || 0))
end
end
def refresh_job(%Config{} = conf, %Job{id: job_id} = job) do
query =
Job
|> where(id: ^job_id)
|> select([j], map(j, ^@refresh_fields))
case Repo.all(conf, query) do
[] ->
nil
[new_job] ->
Enum.reduce(new_job, job, fn {key, val}, acc -> %{acc | key => val} end)
end
end
def refresh_job(%Config{} = conf, job_id) when is_binary(job_id) or is_integer(job_id) do
Repo.get(conf, Job, job_id)
end
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))
:ok
end
def cancel_jobs(_conf, _ids), do: :ok
def delete_jobs(%Config{name: name}, [_ | _] = job_ids) do
Oban.delete_all_jobs(name, only_ids(job_ids))
:ok
end
def delete_jobs(_conf, _ids), do: :ok
def retry_jobs(%Config{name: name}, [_ | _] = job_ids) do
Oban.retry_all_jobs(name, only_ids(job_ids))
:ok
end
def retry_jobs(_conf, _ids), do: :ok
# Parsing Helpers
defp parse_term("args:" <> terms) do
{:args, String.trim(terms)}
end
defp parse_term("args." <> path_and_term) do
parse_path(:args, path_and_term)
end
defp parse_term("ids:" <> ids) do
parse_ints(:ids, ids)
end
defp parse_term("meta:" <> terms) do
{:meta, String.trim(terms)}
end
defp parse_term("meta." <> path_and_term) do
parse_path(:meta, path_and_term)
end
defp parse_term("nodes:" <> nodes) do
{:nodes, String.split(nodes, ",")}
end
defp parse_term("priorities:" <> priorities) when byte_size(priorities) > 0 do
parse_ints(:priorities, priorities)
end
defp parse_term("queues:" <> queues) do
{:queues, String.split(queues, ",")}
end
defp parse_term("state:" <> states) do
{:state, String.split(states, ",")}
end
defp parse_term("tags:" <> tags) do
{:tags, String.split(tags, ",")}
end
defp parse_term("workers:" <> workers) do
{:workers, String.split(workers, ",")}
end
defp parse_term(_term), do: {:none, ""}
defp parse_ints(field, value) do
{field,
value
|> String.split(",", trim: true)
|> Enum.map(&String.to_integer/1)}
end
defp parse_path(field, path_and_term) do
case String.split(path_and_term, ":", parts: 2) do
[path, term] ->
{field, [String.split(path, "."), term]}
[path] ->
{field, [String.split(path, "."), ""]}
end
end
# Filter Helpers
defp json_path(path) when is_list(path), do: Enum.join(["$" | path], ".")
defp json_path(path) when is_binary(path), do: "$." <> path
defp only_ids(job_ids), do: where(Job, [j], j.id in ^job_ids)
defp maybe_atomize(val) when is_binary(val), do: String.to_existing_atom(val)
defp maybe_atomize(val), do: val
defp filter({:args, [path, term]}, condition, conf) when is_sqlite(conf) or is_mysql(conf) do
json_path = json_path(path)
cast_term = cast_val(term)
dynamic([j], ^condition and fragment("json_extract(?, ?)", j.args, ^json_path) == ^cast_term)
end
defp filter({:args, [path, term]}, condition, _conf) do
dynamic([j], ^condition and fragment("? @> ?", j.args, ^gen_map(path, term)))
end
defp filter({:ids, ids}, condition, _conf) do
dynamic([j], ^condition and j.id in ^ids)
end
defp filter({:meta, [path, term]}, condition, conf) when is_sqlite(conf) or is_mysql(conf) do
json_path = json_path(path)
cast_term = cast_val(term)
dynamic([j], ^condition and fragment("json_extract(?, ?)", j.meta, ^json_path) == ^cast_term)
end
defp filter({:meta, [path, term]}, condition, _conf) do
dynamic([j], ^condition and fragment("? @> ?", j.meta, ^gen_map(path, term)))
end
defp filter({:nodes, nodes}, condition, conf) when is_sqlite(conf) or is_mysql(conf) do
dynamic([j], ^condition and fragment("?->>'$[0]'", j.attempted_by) in ^nodes)
end
defp filter({:nodes, nodes}, condition, _conf) do
dynamic([j], ^condition and fragment("?[1]", j.attempted_by) in ^nodes)
end
defp filter({:queues, queues}, condition, _conf) do
dynamic([j], ^condition and j.queue in ^queues)
end
defp filter({:priorities, priorities}, condition, _conf) do
dynamic([j], ^condition and j.priority in ^priorities)
end
defp filter({:state, state}, condition, _conf) do
dynamic([j], ^condition and j.state == ^state)
end
defp filter({:tags, tags}, condition, conf) when is_mysql(conf) do
dynamic([j], ^condition and fragment("json_overlaps(?, ?)", j.tags, ^tags))
end
defp filter({:tags, tags}, condition, conf) when is_sqlite(conf) do
dynamic([j], ^condition and sqlite_contains_any(j.tags, tags))
end
defp filter({:tags, tags}, condition, _conf) do
dynamic([j], ^condition and fragment("? && ?", j.tags, ^tags))
end
defp filter({:workers, workers}, condition, _conf) do
dynamic([j], ^condition and j.worker in ^workers)
end
defp filter(_, condition, _conf), do: condition
defp gen_map(path, val) do
gen_map(path, cast_val(val), {[], %{}})
end
defp gen_map([], _val, {_path, acc}), do: acc
defp gen_map([key], val, {path, acc}), do: put_in(acc, path ++ [key], val)
defp gen_map([key | tail], val, {path, acc}) do
gen_map(tail, val, {path ++ [key], put_in(acc, path ++ [key], %{})})
end
defp cast_val("true"), do: true
defp cast_val("false"), do: false
defp cast_val(val) do
case Integer.parse(val) do
{int, ""} -> int
_ -> String.trim(val, "\"")
end
end
# Ordering Helpers
defp order(query, :attempt, _state, dir) do
order_by(query, [j], {^dir, j.attempt})
end
defp order(query, :queue, _state, dir) do
order_by(query, [j], {^dir, j.queue})
end
defp order(query, :time, state, dir)
when state in ~w(suspended available retryable scheduled) do
order_by(query, [j], {^dir, j.scheduled_at})
end
defp order(query, :time, "cancelled", dir) do
order_by(query, [j], {^flip_dir(dir), j.cancelled_at})
end
defp order(query, :time, "completed", dir) do
order_by(query, [j], {^flip_dir(dir), j.completed_at})
end
defp order(query, :time, "executing", dir) do
order_by(query, [j], {^dir, j.attempted_at})
end
defp order(query, :time, "discarded", dir) do
order_by(query, [j], {^flip_dir(dir), j.discarded_at})
end
defp order(query, :worker, _state, dir) do
order_by(query, [j], {^dir, j.worker})
end
defp flip_dir(:asc), do: :desc
defp flip_dir(:desc), do: :asc
end

View file

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

View file

@ -0,0 +1,196 @@
defmodule Oban.Web.QueueQuery do
@moduledoc false
alias Oban.Met
alias Oban.Web.{Queue, Search}
@suggest_qualifier [
{"nodes:", "host name", "nodes:machine@somehost"},
{"modes:", "a concurrency mode such as global", "global"},
{"stats:", "a status such as paused or terminating", "paused"}
]
@suggest_mode [
{"global_limit", "the queue is has a global limit", "global_limit"},
{"rate_limit", "the queue is has a rate limit", "rate_limit"}
]
@suggest_stat [
{"paused", "the queue is paused on some nodes", "paused"},
{"terminating", "the queue is shutting down", "terminating"}
]
@known_qualifiers MapSet.new(@suggest_qualifier, fn {qualifier, _, _} -> qualifier end)
# Searching
def filterable, do: ~w(modes nodes stats)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
["modes", frag] -> suggest_static(frag, @suggest_mode)
["nodes", frag] -> suggest_nodes(frag, conf)
["stats", frag] -> suggest_static(frag, @suggest_stat)
[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_nodes(fragment, conf) do
conf.name
|> Oban.Met.labels("node")
|> 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("nodes:" <> nodes) do
{:nodes, String.split(nodes, ",")}
end
defp parse_term("modes:" <> modes) do
{:modes, String.split(modes, ",")}
end
defp parse_term("stats:" <> stats) do
{:stats, String.split(stats, ",")}
end
defp parse_term(_term), do: {:none, ""}
# Querying
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())
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)
if limit, do: Enum.take(queues, limit), else: queues
end
defp new({name, checks}, counts) do
counts = Map.new(counts, fn {state, counts} -> {state, Map.get(counts, name, 0)} end)
struct!(Queue, %{name: name, checks: checks, counts: counts})
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: {:name, :asc}
# 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(%{checks: checks}, {:nodes, nodes}) do
Enum.any?(checks, &(&1["node"] in nodes))
end
defp filter(queue, {:modes, modes}) do
Enum.all?(modes, fn
"global_limit" -> Queue.global_limit?(queue)
"rate_limit" -> Queue.rate_limit?(queue)
end)
end
defp filter(queue, {:stats, stats}) do
Enum.all?(stats, fn
"paused" -> Queue.any_paused?(queue)
"terminating" -> Queue.terminating?(queue)
end)
end
# Sorting
defp order(%{counts: counts}, :avail) do
Map.get(counts, "available", 0)
end
defp order(%{counts: counts}, :exec) do
Map.get(counts, "executing", 0)
end
defp order(queue, :local) do
Queue.local_limit(queue)
end
defp order(queue, :global) do
Queue.global_limit(queue)
end
defp order(%{name: name}, :name), do: name
defp order(%{checks: checks}, :nodes) do
length(checks)
end
defp order(%{checks: checks}, :rate_limit) do
checks
|> Enum.map(&get_in(&1, ["rate_limit", "windows"]))
|> Enum.reject(&is_nil/1)
|> List.flatten()
|> Enum.reduce(0, &(&1["curr_count"] + &1["prev_count"] + &2))
end
defp order(%{checks: checks}, :started) do
started_at_to_diff = fn started_at ->
{:ok, date_time, _} = DateTime.from_iso8601(started_at)
DateTime.diff(date_time, DateTime.utc_now())
end
checks
|> Enum.map(& &1["started_at"])
|> Enum.map(started_at_to_diff)
|> Enum.max()
end
end

View file

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

68
vendor/oban_web/lib/oban/web/queue.ex vendored Normal file
View file

@ -0,0 +1,68 @@
defmodule Oban.Web.Queue do
@moduledoc false
alias __MODULE__
# A struct to encapsulate queues and filtering functions
defstruct [:name, :checks, :counts]
def local_limit(%Queue{checks: checks}) do
Enum.reduce(checks, 0, &((&1["limit"] || &1["local_limit"]) + &2))
end
def global_limit(%Queue{checks: checks}) do
Enum.find_value(checks, &get_in(&1, ["global_limit", "allowed"]))
end
def total_limit(%Queue{checks: checks}) do
Enum.reduce(checks, 0, &total_limit/2)
end
defp total_limit(%{"global_limit" => %{"allowed" => limit}}, _total), do: limit
defp total_limit(%{"local_limit" => limit}, total) when is_integer(limit), do: total + limit
defp total_limit(%{"limit" => limit}, total) when is_integer(limit), do: total + limit
defp total_limit(_payload, total), do: total
def started_at(%Queue{checks: checks}) do
checks
|> List.wrap()
|> Enum.map(& &1["started_at"])
|> Enum.map(&started_at_to_diff/1)
|> Enum.max()
end
defp started_at_to_diff(started_at) do
{:ok, date_time, _} = DateTime.from_iso8601(started_at)
DateTime.diff(date_time, DateTime.utc_now())
end
# Predicates
def all_paused?(%Queue{checks: checks}) do
Enum.all?(checks, & &1["paused"])
end
def any_paused?(%Queue{checks: checks}) do
Enum.any?(checks, & &1["paused"])
end
def global_limit?(%Queue{checks: checks}) do
Enum.any?(checks, &is_map(&1["global_limit"]))
end
def rate_limit?(%Queue{checks: checks}) 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
end

506
vendor/oban_web/lib/oban/web/resolver.ex vendored Normal file
View file

@ -0,0 +1,506 @@
defmodule Oban.Web.Resolver do
@moduledoc """
Web customization is done through a callback module that implements the this behaviour.
## Usage
Each callback is optional and resolution falls back to the default implementation when a
callback is omittied. Here is an example implementation that defines all callbacks with their
default values for reference:
```elixir
defmodule MyApp.Resolver do
@behaviour Oban.Web.Resolver
@impl true
def resolve_user(_conn), do: nil
@impl true
def resolve_access(_user), do: :all
@impl true
def resolve_instances(_user), do: :all
@impl true
def resolve_refresh(_user), do: 1
@impl true
def format_job_args(%Oban.Job{args: args}) do
inspect(args, charlists: :as_lists, pretty: true)
end
@impl true
def format_job_meta(%Oban.Job{meta: meta}) do
inspect(meta, charlists: :as_lists, pretty: true)
end
@impl true
def format_recorded(recorded, _job) do
recorded
|> Oban.Web.Resolver.decode_recorded()
|> inspect(charlists: :as_lists, pretty: true)
end
@impl true
def jobs_query_limit(_state), do: 100_000
@impl true
def hint_query_limit(_qualifier), do: 10_000
@impl true
def bulk_action_limit(_state), do: 1_000
end
```
To use a resolver such as `MyApp.Resolver` defined above, you pass it through as an option to
`oban_dashboard/2` in your application's router:
```elixir
scope "/" do
pipe_through :browser
oban_dashboard "/oban", resolver: MyApp.Resolver
end
```
## Overview
Details about each callback's functionality can be found in the callback docs. Here's a quick
summary of each callback and its purpose:
* [Current User](#c:resolve_user/1)—Extract the current user for access controls when the
dashboard mounts.
* [Action Controls](#c:resolve_access/1)—Restrict which operations users may perform or forbid
access to the dashboard.
* [Jobs Query Limit](#c:jobs_query_limit/1)—Control the maximum number of jobs to query when
filtering, searching, and otherwise listing jobs.
* [Hint Query Limit](#c:hint_query_limit/1)—Control the maximum number of jobs to search for
auto-complete hints.
* [Bulk Action Limit](#c:bulk_action_limit/1)—Control the maximum number of jobs that can be
acted on at once, e.g. cancelled.
* [Format Job Args](#c:format_job_args/1)—Override the default verbose args formatting.
* [Format Job Meta](#c:format_meta_args/1)—Override the default verbose meta formatting.
* [Default Refresh](#c:resolve_refresh/1)—Set the default refresh interval for new sessions.
## Authentication
By combining `resolve_user/1` and `resolve_access/1` callbacks it's possible to build an
authentication solution around the dashboard. For example, this resolver extracts the
`current_user` from the conn's assigns map and then scopes their access based on role. If it is
a standard user or `nil` then they're redirected to `/login` when the dashboard mounts.
```elixir
defmodule MyApp.Resolver do
@behaviour Oban.Web.Resolver
@impl true
def resolve_user(conn) do
conn.assigns.current_user
end
@impl true
def resolve_access(user) do
case user do
%{admin?: true} -> :all
%{staff?: true} -> :read_only
_ -> {:forbidden, "/login"}
end
end
end
```
See the [Limiting Access](limiting_access.md) guide for more patterns, including fine-grained
action controls and integration with authentication systems.
"""
alias Oban.Job
@type access :: :all | :read_only | [{access_option(), boolean()}] | {:forbidden, Path.t()}
@type access_option ::
:cancel_jobs
| :cancel_workflows
| :delete_crons
| :delete_jobs
| :insert_crons
| :insert_jobs
| :pause_crons
| :pause_queues
| :retry_jobs
| :retry_workflows
| :scale_queues
| :stop_queues
| :update_crons
@type qualifier :: :args | :meta | :nodes | :queues | :tags | :workers
@type refresh :: 1 | 2 | 5 | 15 | 60 | -1
@type user :: term()
@doc """
Explicitly allow Oban instances to be selectable.
## Examples
Allow admins to access all instances and others to only access the default:
def resolve_instances(%{admin?: true}), do: :all
def resolve_instances(_user), do: [Oban]
"""
@callback resolve_instances(user :: user()) :: :all | [GenServer.name()]
@doc """
Customize the formatting of job args wherever they are displayed.
This callback allows for full over args formatting; whether for privacy, brevity, or clarity. By
default, all `args` are preserved in table and detail views.
## Examples
Redact the `"email"` field for only the `SecretJob` worker:
def format_job_args(%Oban.Job{worker: "MyApp.SecretJob", args: args}) do
args
|> Map.replace("email", "REDACTED")
|> inspect(pretty: true)
end
def format_job_args(job), do: Oban.Web.Resolver.format_job_args(job)
"""
@callback format_job_args(job :: Job.t()) :: iodata()
@doc """
Customize the formatting of job meta wherever it is displayed.
This callback behaves identically to `c:format_job_args/1`, but is used for `meta`.
## Examples
Msk the `batch_id` for some secret batch jobs:
def format_job_meta(%Oban.Job{meta: %{"batch_id" => _batch} = meta}) do
meta
|> Map.replace("batch_id", "SECRET BATCHES")
|> inspect(pretty: true)
end
def format_job_meta(job), do: Oban.Web.Resolver.format_job_meta(job)
"""
@callback format_job_meta(job :: Job.t()) :: iodata()
@doc """
Customize the formatting of recorded output wherever it is displayed.
This callback is similar to `c:format_job_args/1`, but it accepts both the recorded binary and
the job to help augment the output.
Note that you **must decode the recorded binary** prior to inspecting it.
## Examples
Disable pretty printing and change the output width to 98 characters:
def format_recorded(recorded, _job) do
recorded
|> Oban.Web.Resolver.decode_recorded()
|> inspect(pretty: false, width: 98)
end
Decode the recorded value without the `:safe` flag set, to allow decoding terms with unknown
atoms:
def format_recorded(recorded, _job) do
recorded
|> Oban.Web.Resolver.decode_recorded([])
|> inspect(pretty: false, width: 98)
end
Display job args alongside recorded output:
def format_recorded(recorded, %Oban.Job{args: args}) when is_map(recorded) do
recorded
|> Oban.Web.Resolver.decode_recorded()
|> Map.put(:args, args)
|> inspect(charlists: :as_lists, pretty: true)
end
"""
@callback format_recorded(recorded :: term(), job :: Job.t()) :: iodata()
@doc """
Extract the current user from a `Plug.Conn` when the dashboard mounts.
The extracted user is passed to all of the other callback functions, allowing you to customize the
dashboard per user or role.
This callback is expected to return `nil`, a map or a struct. However, the resolved user is only
passed to other functions in the `Resolver` and as part of the metadata for audit events, so
you're free to use any data type you like.
## Examples
Extract the user from the `assigns` map in a typical plug based auth setup:
def resolve_user(conn) do
conn.assigns.current_user
end
"""
@callback resolve_user(conn :: Plug.Conn.t()) :: user()
@doc """
Determine the appropriate access level for a user.
During normal operation users can modify running queues and interact with jobs through the
dashboard. In some situations actions such as pausing a queue may be undesired, or even
dangerous for operations.
Through this callback you can tailor precisely which actions the current user can do. The
default access level is `:all`, which permits _all_ users to do any action.
Returning `{:forbidden, path}` prevents loading the dashboard entirely and redirects the user to
the provided path.
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`
* `:update_crons`
Actions which aren't listed are considered disabled.
## Examples
To set the dashboard read only and prevent users from performing any actions at all:
def resolve_access(_user), do: :read_only
Forbid any user that isn't an admin and redirect them to the root:
def resolve_access(user) do
if user.admin?, do: :all, else: {:forbidden, "/"}
end
Alternatively, you can use the resolved `user` to allow admins write access and keep all other
users read only:
def resolve_access(user) do
if user.admin?, do: :all, else: :read_only
end
You can also specify fine grained access for each of the possible dashboard actions.
def resolve_access(user) do
if user.admin? do
[cancel_jobs: true, delete_jobs: true, retry_jobs: true]
else
:read_only
end
end
"""
@callback resolve_access(user()) :: access()
@doc """
Determine the initial refresh rate when the dashboard mounts.
The refresh rate controls how frequently the server pulls statistics from the database, and when
data is pushed from the server. The default refresh rate is 1 second.
Possible values are: `1`, `2`, `5`, `15` or `-1` to disable refreshing.
Note that this only sets the default. Users may still choose a different refresh for themselves
while viewing the dashboard and their chosen value will stick for future sessions.
## Examples
Default to 5 seconds:
def resolve_refresh(_user), do: 5
"""
@callback resolve_refresh(user()) :: refresh()
@doc """
The maximum number of jobs to query when displaying jobs.
Ordering is applied _after_ limiting the query. That means the "oldest" job displayed will
always be within the filter limit. For example, with a limit of 100k jobs and 200k completed
jobs, only the latest 100k are queryable and the oldest 100k are effectively invisible.
In the interest of speed, limits are only approximate.
The limit may be determined by state, e.g. `:completed` or `:cancelled`, to fine-tune query
performance for larger states. Limiting may be disabled with `:infinity`.
Without a callback implemented, the `:completed` state defaults to a conservative 100k jobs and all
other states are `:infinite`.
## Example
Restrict the limit for all states:
def jobs_query_limit(_state), do: 50_000
Use a conservative the limit for `:completed` without any limit for other states (this is the
default):
def jobs_query_limit(:completed), do: 100_000
def jobs_query_limit(_state), do: :infinity
"""
@callback jobs_query_limit(Job.unique_state()) :: :infinity | pos_integer()
@doc """
The maximum number of jobs to query for auto-complete suggestions.
The limit may be determined by qualifier, e.g. `:args` or `:worker`, to fine-tune the impact of
suggestion queries. Limiting may be disabled with `:infinity`.
Without a callback implemented it defaults to 10,000 for all qualifiers.
## Example
Increase the limit to 20k for all qualifiers:
def hint_query_limit(_qualifier), do: 20_000
Customize the limit for `args` and `meta`, falling back to 10k for everything else.
def hint_query_limit(:args), do: 5_000
def hint_query_limit(:meta), do: 2_000
def hint_query_limit(_qualifier), do: 10_000
"""
@callback hint_query_limit(qualifier()) :: :infinity | pos_integer()
@doc """
The maximum number of jobs that can be selected and operated on in bulk.
The limit may be determined by state, e.g. `:completed` or `:cancelled`, to fine-tune query
performance for larger states. Limiting may be disabled with `:infinity`.
Without a callback implemented all states default to 1000 jobs.
## Example
Bump the limit for all states:
def bulk_action_limit(_state), do: 5_000
Use a lower limit for `:completed` without any limit for other states:
def bulk_action_limit(:completed), do: 10_000
def bulk_action_limit(_state), do: :infinity
"""
@callback bulk_action_limit(Job.unique_state()) :: :infinity | pos_integer()
@optional_callbacks format_job_args: 1,
format_job_meta: 1,
format_recorded: 2,
bulk_action_limit: 1,
hint_query_limit: 1,
jobs_query_limit: 1,
resolve_user: 1,
resolve_access: 1,
resolve_instances: 1,
resolve_refresh: 1
@inspect_opts [charlists: :as_lists, pretty: true]
@doc """
Decode a recorded job's output from a compressed, base64 binary into proper terms.
By default, decoding uses the `:safe` flag to prevent decoding unsafe data that can be used to
attack the Erlang runtime. In addition to preventing atom creation, the `:safe` flag will also
prevent decoding "executable" objects such as functions.
## Example
Decode a recorded binary:
iex> Oban.Web.Resolver.decode_recorded("g3QAAAABdwRuYW1lbQAAAARvYmFu")
%{name: "oban"}
Decode without safety:
iex> Oban.Web.Resolver.decode_recorded("g3QAAAABdwRhdG9tdwd1bmtub3du", [])
%{atom: :unknown}
"""
@spec decode_recorded(binary(), [:safe]) :: term()
def decode_recorded(bin, opts \\ [:safe]) do
term = Base.decode64!(bin, padding: false)
if Enum.member?(opts, :safe) do
Plug.Crypto.non_executable_binary_to_term(term, opts)
else
:erlang.binary_to_term(term)
end
end
@doc false
def call_with_fallback(resolver, fun, args) when is_atom(fun) and is_list(args) do
resolver =
if Code.ensure_loaded?(resolver) and function_exported?(resolver, fun, length(args)) do
resolver
else
__MODULE__
end
apply(resolver, fun, args)
end
@doc false
def format_job_args(%Job{args: %{"arg" => "term-" <> _} = args}) do
args
|> Map.update!("arg", &(&1 |> String.trim_leading("term-") |> decode_recorded()))
|> inspect(@inspect_opts)
end
def format_job_args(%Job{args: args}), do: inspect(args, @inspect_opts)
@doc false
def format_job_meta(%Job{meta: meta}), do: inspect(meta, @inspect_opts)
@doc false
def format_recorded(recorded, _job) do
recorded
|> decode_recorded()
|> inspect(@inspect_opts)
end
@doc false
def resolve_user(_conn), do: nil
@doc false
def resolve_access(_user), do: :all
@doc false
def resolve_instances(_user), do: :all
@doc false
def resolve_refresh(_user), do: 1
@doc false
def jobs_query_limit(:completed), do: 100_000
def jobs_query_limit(_state), do: :infinity
@doc false
def hint_query_limit(_qualifier), do: 10_000
@doc false
def bulk_action_limit(_state), do: 1_000
end

330
vendor/oban_web/lib/oban/web/router.ex vendored Normal file
View file

@ -0,0 +1,330 @@
defmodule Oban.Web.Router do
@moduledoc """
Provides mount points for the Web dashboard with customization.
### Customizing with a Resolver Callback Module
Implementing a `Oban.Web.Resolver` callback module allows you to customize the dashboard
per-user, i.e. setting access controls or the default refresh rate.
As a simple example, let's define a module that makes the dashboard read only:
```elixir
defmodule MyApp.Resolver do
@behaviour Oban.Web.Resolver
@impl true
def resolve_access(_user), do: :read_only
end
```
Then specify `MyApp.Resolver` as your resolver:
```elixir
scope "/" do
pipe_through :browser
oban_dashboard "/oban", resolver: MyApp.Resolver
end
```
See the `Oban.Web.Resolver` docs for more details.
### Running Multiple Dashboards
A single dashboard may connect to any number of running Oban instances. However, it's also
possible to change the default instance via the `:oban_name` option. For example, given two
configured Oban instances, `Oban` and `MyAdmin.Oban`, you can then mount both dashboards in your
router:
```elixir
scope "/" do
pipe_through :browser
oban_dashboard "/oban", oban_name: Oban, as: :oban_dashboard
oban_dashboard "/admin/oban", oban_name: MyAdmin.Oban, as: :oban_admin_dashboard
end
```
Note that the default name is `Oban` or the first found instance, setting `oban_name: Oban` in
the example above was purely for demonstration purposes.
### On Mount Hooks
You can provide a list of hooks to attach to the dashboard's mount lifecycle. Additional hooks
are prepended before [Oban Web's own Authentication](Oban.Web.Resolver). For example, to run a
user-fetching hook and an activation checking hook before mount:
```elixir
scope "/" do
pipe_through :browser
oban_dashboard "/oban", on_mount: [MyApp.UserHook, MyApp.ActivatedHook]
end
```
### Customizing the Socket Connection
Applications that use a live socket other than "/live" can override the default socket path in
the router. For example, if your live socket is hosted at `/oban_live`:
```elixir
socket "/oban_live", Phoenix.LiveView.Socket
scope "/" do
pipe_through :browser
oban_dashboard "/oban", socket_path: "/oban_live"
end
```
If your application is hosted in an environment that doesn't support websockets you can use
longpolling as an alternate transport. To start, make sure that your live socket is configured
for longpolling:
```elixir
socket "/live", Phoenix.LiveView.Socket,
longpoll: [connect_info: [session: @session_options], log: false]
```
Then specify "longpoll" as your transport:
```elixir
scope "/" do
pipe_through :browser
oban_dashboard "/oban", transport: "longpoll"
end
```
### Content Security Policy
To secure the dashboard, or comply with an existing CSP within your application, you can specify
nonce keys for images, scripts and styles.
You'll configure the CSP nonce assign key in your router, where the dashboard is mounted. For
example, to use a single nonce for all three asset types:
```elixir
oban_dashboard("/oban", csp_nonce_assign_key: :my_csp_nonce)
```
That instructs the dashboard to extract a generated nonce from the `assigns` map on the plug
connection, at the `:my_csp_nonce` key.
Instead, you can specify different keys for each asset type:
```elixir
oban_dashboard("/oban",
csp_nonce_assign_key: %{
img: :img_csp_nonce,
style: :style_csp_nonce,
script: :script_csp_nonce
}
)
```
"""
alias Oban.Web.Resolver
@default_opts [
resolver: Resolver,
socket_path: "/live",
transport: "websocket"
]
@transport_values ~w(longpoll websocket)
@doc """
Defines an oban dashboard route.
It requires a path where to mount the dashboard at and allows options to customize routing.
## Options
* `:as` override the route name; otherwise defaults to `:oban_dashboard`
* `:csp_nonce_assign_key` CSP (Content Security Policy) keys used to authenticate image,
style, and script assets by pulling a generated nonce out of the connection's `assigns` map. May
be `nil`, a single atom, or a map of atoms. Defaults to `nil`.
* `:logo_path` a custom path for the logo link in the header, allowing the logo to link to
another page in your application instead of the Oban dashboard root. Defaults to the jobs page.
* `:on_mount` declares additional module callbacks to be invoked when the dashboard mounts
* `:oban_name` name of the Oban instance the dashboard will use for configuration and
notifications, defaults to `Oban` or the first instance that can be found.
* `:resolver` an `Oban.Web.Resolver` implementation used to customize the dashboard's
functionality.
* `:socket_path` a phoenix socket path for live communication, defaults to `"/live"`.
* `:transport` a phoenix socket transport, either `"websocket"` or `"longpoll"`, defaults to
`"websocket"`.
## Examples
Mount an `oban` dashboard at the path "/oban":
defmodule MyAppWeb.Router do
use Phoenix.Router
import Oban.Web.Router
scope "/", MyAppWeb do
pipe_through [:browser]
oban_dashboard "/oban"
end
end
"""
defmacro oban_dashboard(path, opts \\ []) do
opts =
if Macro.quoted_literal?(opts) do
Macro.prewalk(opts, &expand_alias(&1, __CALLER__))
else
opts
end
quote bind_quoted: binding() do
prefix = Phoenix.Router.scoped_path(__MODULE__, path)
scope path, alias: false, as: false do
import Phoenix.LiveView.Router, only: [live: 4, live_session: 3]
{session_name, session_opts, route_opts} = Oban.Web.Router.__options__(prefix, opts)
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
live "/:page/:id", Oban.Web.DashboardLive, :show, route_opts
end
end
end
end
defp expand_alias({:__aliases__, _, _} = alias, env) do
Macro.expand(alias, %{env | function: {:oban_dashboard, 2}})
end
defp expand_alias(other, _env), do: other
@doc false
def __options__(prefix, opts) do
opts = Keyword.merge(@default_opts, opts)
Enum.each(opts, &validate_opt!/1)
on_mount = Keyword.get(opts, :on_mount, []) ++ [Oban.Web.Authentication]
session_args = [
prefix,
opts[:oban_name],
opts[:resolver],
opts[:socket_path],
opts[:transport],
opts[:csp_nonce_assign_key],
opts[:logo_path]
]
session_opts = [
on_mount: on_mount,
session: {__MODULE__, :__session__, session_args},
root_layout: {Oban.Web.Layouts, :root}
]
session_name = Keyword.get(opts, :as, :oban_dashboard)
{session_name, session_opts, as: session_name}
end
@doc false
def __session__(conn, prefix, oban, resolver, live_path, live_transport, csp_key, logo_path) do
user = Resolver.call_with_fallback(resolver, :resolve_user, [conn])
csp_keys = expand_csp_nonce_keys(csp_key)
%{
"prefix" => prefix,
"oban" => oban,
"user" => user,
"resolver" => resolver,
"access" => Resolver.call_with_fallback(resolver, :resolve_access, [user]),
"refresh" => Resolver.call_with_fallback(resolver, :resolve_refresh, [user]),
"live_path" => live_path,
"live_transport" => live_transport,
"logo_path" => logo_path,
"csp_nonces" => %{
img: conn.assigns[csp_keys[:img]],
style: conn.assigns[csp_keys[:style]],
script: conn.assigns[csp_keys[:script]]
}
}
end
defp expand_csp_nonce_keys(nil), do: %{img: nil, style: nil, script: nil}
defp expand_csp_nonce_keys(key) when is_atom(key), do: %{img: key, style: key, script: key}
defp expand_csp_nonce_keys(map) when is_map(map), do: map
defp validate_opt!({:csp_nonce_assign_key, key}) do
unless is_nil(key) or is_atom(key) or is_map(key) do
raise ArgumentError, """
invalid :csp_nonce_assign_key, expected nil, an atom or a map with atom keys,
got #{inspect(key)}
"""
end
end
defp validate_opt!({:logo_path, path}) do
unless is_nil(path) or (is_binary(path) and byte_size(path) > 0) do
raise ArgumentError, """
invalid :logo_path, expected nil or a non-empty binary path,
got: #{inspect(path)}
"""
end
end
defp validate_opt!({:oban_name, name}) do
unless is_atom(name) do
raise ArgumentError, """
invalid :oban_name, expected a module or atom,
got #{inspect(name)}
"""
end
end
defp validate_opt!({:resolver, resolver}) do
unless is_atom(resolver) and not is_nil(resolver) do
raise ArgumentError, """
invalid :resolver, expected a module that implements the Oban.Web.Resolver behaviour,
got: #{inspect(resolver)}
"""
end
end
defp validate_opt!({:socket_path, path}) do
unless is_binary(path) and byte_size(path) > 0 do
raise ArgumentError, """
invalid :socket_path, expected a binary URL, got: #{inspect(path)}
"""
end
end
defp validate_opt!({:transport, transport}) do
unless transport in @transport_values do
raise ArgumentError, """
invalid :transport, expected one of #{inspect(@transport_values)},
got #{inspect(transport)}
"""
end
end
defp validate_opt!(_option), do: :ok
end

80
vendor/oban_web/lib/oban/web/search.ex vendored Normal file
View file

@ -0,0 +1,80 @@
defmodule Oban.Web.Search do
@moduledoc false
# Helpers for the search toolbar
@ignored_chars ~w(; / \ ` ' = * ! ? # $ & + ^ | ~ < > ( \) { } [ ])
# Suggestion tuning
@suggest_limit 10
@suggest_threshold 0.5
@doc """
Parse a string of qualifiers and values into structured search terms.
"""
def parse(terms, parser) when is_binary(terms) and is_function(parser, 1) do
terms
|> String.split(~r/\s+(?=([^\"]*\"[^\"]*\")*[^\"]*$)/, trim: true)
|> Map.new(fn term ->
term
|> String.replace(@ignored_chars, "")
|> parser.()
end)
end
@doc """
Append to the terms string without any duplication.
"""
def append(terms, choice, known)
when is_binary(terms) and is_binary(choice) and is_struct(known, MapSet) do
choice = if String.match?(choice, ~r/[\s,]/), do: ~s("#{choice}"), else: choice
cond do
MapSet.member?(known, choice) ->
choice
String.contains?(terms, ":") ->
[qualifier, _] = String.split(terms, ":", parts: 2)
"#{qualifier}:#{choice}"
true ->
terms
|> String.reverse()
|> String.split(["."], parts: 2)
|> List.last()
|> String.reverse()
|> Kernel.<>(".#{choice}")
end
end
@doc """
Restrict suggestions by similarity to a given fragment.
"""
def restrict_suggestions(suggestions, "") do
suggestions
|> Enum.sort()
|> Enum.take(@suggest_limit)
|> Enum.map(&{&1, "", ""})
end
def restrict_suggestions(suggestions, frag) do
suggestions
|> Enum.filter(&(similarity(&1, frag) >= @suggest_threshold))
|> Enum.sort_by(&{1.0 - similarity(&1, frag), &1}, :asc)
|> Enum.take(@suggest_limit)
|> Enum.map(&{&1, "", ""})
end
defp similarity(value, guess, boost \\ 0.5) do
value = String.downcase(value)
guess = String.downcase(guess)
distance = String.jaro_distance(value, guess)
if String.contains?(value, guess) do
min(distance + boost, 1.0)
else
distance
end
end
end

View file

@ -0,0 +1,183 @@
defmodule Oban.Web.Telemetry do
@moduledoc """
Oban Web uses `Telemetry` to provide instrumentation and to power logging of dashboard activity.
## Action Events
An action event is emitted whenever a user performs a write operation with the dashboard, e.g.
pausing a queue, cancelling a job, etc.
The dashboard emits the following events:
* `[:oban_web, :action, :start]`
* `[:oban_web, :action, :stop]`
* `[:oban_web, :action, :exception]`
Action events include the action name, Oban config, the user that performed the action (if
available), and relevant metadata. In addition, failed actions provide the error type, the error
itself, and the stacktrace.
The following chart shows the _base metadata_ for each event:
| event | measures | metadata |
| ------------ | ---------------| ----------------------------------------------------- |
| `:start` | `:system_time` | `:action, :config, :user` |
| `:stop` | `:duration` | `:action, :config, :user` |
| `:exception` | `:duration` | `:action, :config, :user, :kind, :error, :stacktrace` |
For `:exception` events the metadata includes details about what caused the failure. The `:kind`
value is determined by how an error occurred.
This chart breaks down the possible actions and their specific metadata:
| action | metadata |
| -------------------- | ------------------ |
| `:mount` | |
| `:pause_queue` | `:queue` |
| `:resume_queue` | `:queue` |
| `:pause_queues` | `:queues` |
| `:resume_queues` | `:queues` |
| `:stop_queues` | `:queues` |
| `:pause_all_queues` | |
| `:resume_all_queues` | |
| `:scale_queue` | `:queue`, `:limit` |
| `:cancel_jobs` | `:job_ids` |
| `:delete_jobs` | `:job_ids` |
| `:retry_jobs` | `:job_ids` |
## Action Logging
The `Oban.Web.Telemetry` module ships with a default handler that logs structured JSON for
`:stop` and `:exception` events. To attach the logger, call `attach_default_logger/1` as your
application starts:
```elixir
def start(_type, _args) do
children = [
MyApp.Repo,
MyApp.Endpoint,
{Oban, oban_opts()}
]
Oban.Telemetry.attach_default_logger(:info)
Oban.Web.Telemetry.attach_default_logger(:info)
Supervisor.start_link(children, [strategy: :one_for_one, name: MyApp.Supervisor])
end
```
"""
require Logger
@doc """
Attaches a structured telemetry handler for logging.
## Options
* `:level` The log level to use for logging output, defaults to `:info`
* `:encode` Whether to encode log output as JSON, defaults to `true`
## Events
Here is an example of the JSON output for an `action:stop` event:
```json
{
"action":"cancel_jobs",
"duration":2544,
"event":"action:stop",
"job_ids":[290950],
"oban_name":"Oban",
"source":"oban_web",
"user":1818
}
```
Event metadata is passed through directly along with these constant fields:
* `action` The reported action, e.g. `cancel_jobs`
* `duration` Action duration, recorded in native units and logged as microseconds
* `source` Always "oban_web", which is useful for log filtering
* `user` If the dashboard was mounted with an `Oban.Web.Resolver` that implements
`resolve_user/1` this is the user's id, otherwise `null`
* `oban_name` The instance that the dashboard is linked to, typically this is
"Oban" unless an application is using multiple Oban instances.
## Examples
Attach the logger at the `:debug` log level:
Oban.Web.Telemetry.attach_default_logger(level: :debug)
Disable JSON encoding output:
Oban.Web.Telemetry.attach_default_logger(encode: false)
"""
def attach_default_logger(opts \\ [encode: true, level: :info])
def attach_default_logger(level) when is_atom(level) do
attach_default_logger(level: level)
end
def attach_default_logger(opts) when is_list(opts) do
events = [
[:oban_web, :action, :stop],
[:oban_web, :action, :exception]
]
opts =
opts
|> Keyword.put_new(:encode, true)
|> Keyword.put_new(:level, :info)
:telemetry.attach_many("oban_web-logger", events, &__MODULE__.handle_event/4, opts)
end
@doc false
def action(name, socket, meta, fun) do
meta =
meta
|> Map.new()
|> Map.put(:action, name)
|> Map.put(:user, socket.assigns.user)
|> Map.put(:config, socket.assigns.conf)
:telemetry.span([:oban_web, :action], meta, fn -> {fun.(), meta} end)
end
@doc false
def handle_event([:oban_web, :action, event], measure, meta, opts) do
level = Keyword.fetch!(opts, :level)
Logger.log(level, fn ->
{conf, meta} = Map.pop(meta, :conf)
{user, meta} = Map.pop(meta, :user)
output =
meta
|> Map.take([:queue, :limit, :job_ids, :kind])
|> Map.put(:user, inspect_user(user))
|> Map.put(:oban_name, inspect_config(conf))
|> Map.put(:duration, System.convert_time_unit(measure.duration, :native, :microsecond))
|> Map.put(:event, "action:#{event}")
|> Map.put(:source, "oban_web")
if Keyword.fetch!(opts, :encode) do
Oban.JSON.encode_to_iodata!(output)
else
output
end
end)
end
defp inspect_config(%{name: oban_name}), do: inspect(oban_name)
defp inspect_config(_), do: "Oban"
defp inspect_user(%{id: id}), do: id
defp inspect_user(_), do: nil
end

View file

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

276
vendor/oban_web/lib/oban/web/timing.ex vendored Normal file
View file

@ -0,0 +1,276 @@
defmodule Oban.Web.Timing do
@moduledoc false
@empty_time "-"
@doc """
Snap unix time to an even value for a given increment.
iex> Oban.Web.Timing.snap(1685707957, 1)
1685707957
iex> Oban.Web.Timing.snap(1685707957, 5)
1685707960
iex> Oban.Web.Timing.snap(1685707957, 30)
1685707980
"""
def snap(unix, step) when rem(unix, step) == 0, do: unix
def snap(unix, step), do: snap(unix + 1, step)
@doc """
Format ellapsed seconds into a timer format of "MM:SS" or "HH:MM:SS".
iex> Oban.Web.Timing.to_duration(0)
"00:00"
iex> Oban.Web.Timing.to_duration(5)
"00:05"
iex> Oban.Web.Timing.to_duration(-5)
"00:05"
iex> Oban.Web.Timing.to_duration(60)
"01:00"
iex> Oban.Web.Timing.to_duration(65)
"01:05"
iex> Oban.Web.Timing.to_duration(7199)
"01:59:59"
"""
def to_duration(ellapsed) when is_integer(ellapsed) do
ellapsed = abs(ellapsed)
seconds = Integer.mod(ellapsed, 60)
minutes = ellapsed |> Integer.mod(3_600) |> div(60)
hours = div(ellapsed, 3_600)
parts = [minutes, seconds]
parts = if hours > 0, do: [hours | parts], else: parts
Enum.map_join(parts, ":", &pad/1)
end
@doc """
Format elapsed seconds with trailing milliseconds.
iex> Oban.Web.Timing.to_duration(0, :millisecond)
"00:00.000"
iex> Oban.Web.Timing.to_duration(5, :millisecond)
"00:00.005"
iex> Oban.Web.Timing.to_duration(-5, :millisecond)
"00:00.005"
iex> Oban.Web.Timing.to_duration(61030, :millisecond)
"01:01.030"
iex> Oban.Web.Timing.to_duration(61930, :millisecond)
"01:01.930"
"""
def to_duration(ellapsed, :millisecond) do
milliseconds =
ellapsed
|> abs()
|> Integer.mod(1000)
|> pad(3)
ellapsed
|> div(1000)
|> to_duration()
|> Kernel.<>(".#{milliseconds}")
end
@doc """
Format ellapsed seconds into a wordy format, based on "distance of time in words".
iex> Oban.Web.Timing.to_words(0)
"now"
iex> Oban.Web.Timing.to_words(1)
"in 1s"
iex> Oban.Web.Timing.to_words(-1)
"1s ago"
iex> Oban.Web.Timing.to_words(-5)
"5s ago"
iex> Oban.Web.Timing.to_words(60)
"in 1m"
iex> Oban.Web.Timing.to_words(121)
"in 2m"
iex> Oban.Web.Timing.to_words(-60 * 60)
"1h ago"
iex> Oban.Web.Timing.to_words(60 * 60)
"in 1h"
iex> Oban.Web.Timing.to_words((60 * 60 * 24) - 1)
"in 23h"
iex> Oban.Web.Timing.to_words(60 * 60 * 24)
"in 1d"
iex> Oban.Web.Timing.to_words(60 * 60 * 24 * 5)
"in 5d"
iex> Oban.Web.Timing.to_words(60 * 60 * 24 * 30)
"in 1mo"
iex> Oban.Web.Timing.to_words(60 * 60 * 24 * 30 * 5)
"in 5mo"
iex> Oban.Web.Timing.to_words(60 * 60 * 24 * 365)
"in 1yr"
iex> Oban.Web.Timing.to_words(60 * 60 * 24 * 365 * 2)
"in 2yr"
The `:relative` option controls whether an "in" or "ago" modifier is added:
iex> Oban.Web.Timing.to_words(1, relative: false)
"1s"
iex> Oban.Web.Timing.to_words(-1, relative: false)
"1s"
"""
def to_words(ellapsed, opts \\ []) when is_integer(ellapsed) do
distance =
case abs(ellapsed) do
0 -> "now"
n when n in 1..59 -> "#{n}s"
n when n in 60..3_599 -> "#{div(n, 60)}m"
n when n in 3_600..86_399 -> "#{div(n, 3_600)}h"
n when n in 86_400..2_591_999 -> "#{div(n, 86_400)}d"
n when n in 2_592_000..31_535_999 -> "#{div(n, 2_592_000)}mo"
n -> "#{div(n, 31_536_000)}yr"
end
cond do
opts[:relative] == false -> distance
ellapsed < 0 -> "#{distance} ago"
ellapsed > 0 -> "in #{distance}"
true -> distance
end
end
@doc """
Calculate the amount of time a job waited between availability and execution.
iex> Oban.Web.Timing.queue_time(%{attempted_at: nil})
"-"
iex> at = DateTime.utc_now()
...> Oban.Web.Timing.queue_time(%{attempted_at: at, inserted_at: at, scheduled_at: at})
"00:00.000"
iex> at_at = DateTime.utc_now()
...> sc_at = DateTime.add(at_at, -60)
...> Oban.Web.Timing.queue_time(%{attempted_at: at_at, inserted_at: sc_at, scheduled_at: sc_at})
"01:00.000"
iex> at_at = DateTime.utc_now()
...> sc_at = DateTime.add(at_at, -60)
...> Oban.Web.Timing.queue_time(%{attempted_at: at_at, inserted_at: sc_at, scheduled_at: sc_at})
"01:00.000"
iex> at_at = DateTime.utc_now()
...> in_at = DateTime.add(at_at, -60)
...> sc_at = DateTime.add(at_at, -90)
...> Oban.Web.Timing.queue_time(%{attempted_at: at_at, inserted_at: in_at, scheduled_at: sc_at})
"01:00.000"
"""
def queue_time(%{attempted_at: nil}), do: @empty_time
def queue_time(job) do
scheduled_or_inserted =
if DateTime.compare(job.scheduled_at, job.inserted_at) == :gt do
job.scheduled_at
else
job.inserted_at
end
job.attempted_at
|> DateTime.diff(scheduled_or_inserted, :millisecond)
|> to_duration(:millisecond)
end
@doc """
Calculate the amount of time a job executed before completing or discarded.
iex> Oban.Web.Timing.run_time(%{attempted_at: nil})
"-"
iex> Oban.Web.Timing.run_time(%{completed_at: nil, discarded_at: nil})
"-"
iex> at = DateTime.utc_now()
...> Oban.Web.Timing.run_time(%{attempted_at: at, completed_at: at})
"00:00.000"
iex> at_at = DateTime.utc_now()
...> co_at = DateTime.add(at_at, -60)
...> Oban.Web.Timing.run_time(%{attempted_at: at_at, completed_at: co_at})
"01:00.000"
iex> at_at = DateTime.utc_now()
...> di_at = DateTime.add(at_at, -60)
...> Oban.Web.Timing.run_time(%{attempted_at: at_at, completed_at: nil, discarded_at: di_at})
"01:00.000"
"""
def run_time(%{attempted_at: nil}), do: @empty_time
def run_time(%{completed_at: nil, discarded_at: nil}), do: @empty_time
def run_time(job) do
finished_at = job.completed_at || job.discarded_at
job.attempted_at
|> DateTime.diff(finished_at, :millisecond)
|> to_duration(:millisecond)
end
@doc """
Select an absolute timestamp appropriate for the provided state and format it.
"""
@spec absolute_time(String.t(), Job.t()) :: String.t()
def absolute_time(state, job) do
case state do
"available" -> "Available At: #{truncate_sec(job.scheduled_at)}"
"cancelled" -> "Cancelled At: #{truncate_sec(job.cancelled_at)}"
"completed" -> "Completed At: #{truncate_sec(job.completed_at)}"
"discarded" -> "Discarded At: #{truncate_sec(job.discarded_at)}"
"executing" -> "Attempted At: #{truncate_sec(job.attempted_at)}"
"retryable" -> "Retryable At: #{truncate_sec(job.scheduled_at)}"
"scheduled" -> "Scheduled At: #{truncate_sec(job.scheduled_at)}"
end
end
defp truncate_sec(datetime), do: NaiveDateTime.truncate(datetime, :second)
@doc """
Convert a naive date time or iso8601 stringified version to a relative time.
"""
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(NaiveDateTime.utc_now())
|> to_words(opts)
end
defp pad(time, places \\ 2) do
time
|> to_string()
|> String.pad_leading(places, "0")
end
end

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

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

View file

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

176
vendor/oban_web/mix.exs vendored Normal file
View file

@ -0,0 +1,176 @@
defmodule Oban.Web.MixProject do
use Mix.Project
@source_url "https://github.com/oban-bg/oban_web"
@version "2.12.1"
def project do
[
app: :oban_web,
version: @version,
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
deps: deps(),
docs: docs(),
aliases: aliases(),
listeners: [Phoenix.CodeReloader],
package: package(),
name: "Oban Web",
description: "Dashboard for the Oban job orchestration framework"
]
end
def application do
[
extra_applications: [:logger],
mod: {Oban.Web.Application, []},
env: [cache: true]
]
end
def cli do
[
preferred_envs: [
precommit: :test,
"test.ci": :test,
"test.reset": :test,
"test.setup": :test
]
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_env), do: ["lib"]
defp package do
[
maintainers: ["Parker Selbert"],
licenses: ["Apache-2.0"],
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",
GitHub: @source_url
}
]
end
defp docs do
[
main: "overview",
source_ref: "v#{@version}",
source_url: @source_url,
formatters: ["html"],
api_reference: false,
extra_section: "GUIDES",
extras: extras(),
groups_for_extras: groups_for_extras(),
logo: "assets/oban-web-logo.svg",
skip_undefined_reference_warnings_on: ["CHANGELOG.md"]
]
end
defp extras do
[
"guides/introduction/overview.md",
"guides/introduction/installation.md",
"guides/introduction/standalone.md",
"guides/advanced/filtering.md",
"guides/advanced/limiting_access.md",
"guides/advanced/metrics.md",
"CHANGELOG.md": [filename: "changelog", title: "Changelog"]
]
end
defp groups_for_extras do
[
Introduction: ~r/guides\/introduction\/.?/,
Advanced: ~r/guides\/advanced\/.?/
]
end
defp deps do
[
{:jason, "~> 1.2"},
{:phoenix, "~> 1.7"},
{:phoenix_html, "~> 3.3 or ~> 4.0"},
{:phoenix_pubsub, "~> 2.1"},
{:phoenix_live_view, "~> 1.0"},
# Oban
{:oban, "~> 2.21"},
{:oban_met, "~> 1.1"},
# Databases
{:ecto_sqlite3, "~> 0.18", only: [:dev, :test]},
{:myxql, "~> 0.7", only: [:dev, :test]},
{:postgrex, "~> 0.19", only: [:dev, :test]},
# Dev Server
{:bandit, "~> 1.5", only: :dev},
{:esbuild, "~> 0.7", only: :dev, runtime: false},
{: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},
{:igniter, "~> 0.5", only: [:dev, :test]},
{:lazy_html, ">= 0.1.0", only: :test},
# Docs and Publishing
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
{:makeup_diff, "~> 0.1", only: :dev, runtime: false}
] ++ oban_pro_dep()
end
defp oban_pro_dep do
if oban_repo_configured?() do
[{:oban_pro, "~> 1.7.0-rc.0", repo: :oban, only: [:test, :dev]}]
else
[]
end
end
defp oban_repo_configured? do
hex_config = Path.expand("~/.hex/hex.config")
case File.read(hex_config) do
{:ok, content} ->
String.contains?(content, "repo.oban.pro")
{:error, _} ->
false
end
end
defp aliases 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",
"cmd git push",
"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": [
"format --check-formatted",
"deps.unlock --check-unused",
"credo --strict",
"test --raise"
]
]
end
end

2
vendor/oban_web/priv/static/app.css vendored Normal file

File diff suppressed because one or more lines are too long

187
vendor/oban_web/priv/static/app.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

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

After

Width:  |  Height:  |  Size: 380 B

View file

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

After

Width:  |  Height:  |  Size: 373 B

View file

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

After

Width:  |  Height:  |  Size: 211 B

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