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:
parent
917e82311a
commit
caad7d90a7
166 changed files with 18616 additions and 1 deletions
|
|
@ -61,6 +61,7 @@ ENV MIX_ENV="prod"
|
|||
|
||||
# install mix dependencies (changes only when mix.exs/mix.lock change)
|
||||
COPY mix.exs mix.lock ./
|
||||
COPY vendor vendor
|
||||
RUN mix deps.get --only $MIX_ENV
|
||||
|
||||
# copy compile-time config before compiling deps
|
||||
|
|
|
|||
|
|
@ -54,6 +54,13 @@ defmodule MicrowavepropWeb.Layouts do
|
|||
>
|
||||
Users
|
||||
</.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 %>
|
||||
<span class="btn btn-ghost btn-sm pointer-events-none opacity-70">
|
||||
{@current_scope.user.callsign}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ defmodule MicrowavepropWeb.Router do
|
|||
use MicrowavepropWeb, :router
|
||||
|
||||
import MicrowavepropWeb.UserAuth
|
||||
import Oban.Web.Router
|
||||
import Phoenix.LiveDashboard.Router
|
||||
|
||||
pipeline :browser do
|
||||
|
|
@ -102,6 +103,8 @@ defmodule MicrowavepropWeb.Router do
|
|||
ecto_repos: [Microwaveprop.Repo],
|
||||
ecto_psql_extras_options: [long_running_queries: [threshold: "200 milliseconds"]],
|
||||
on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}]
|
||||
|
||||
oban_dashboard "/admin/oban", on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}]
|
||||
end
|
||||
|
||||
# Enable Swoosh mailbox preview in development
|
||||
|
|
|
|||
4
mix.exs
4
mix.exs
|
|
@ -66,7 +66,9 @@ defmodule Microwaveprop.MixProject do
|
|||
{:jason, "~> 1.2"},
|
||||
{:bandit, "~> 1.5"},
|
||||
{: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},
|
||||
{:nx, "~> 0.9", only: [:dev, :test]},
|
||||
{:axon, "~> 0.7", only: [:dev, :test]},
|
||||
|
|
|
|||
4
vendor/oban_met/.formatter.exs
vendored
Normal file
4
vendor/oban_met/.formatter.exs
vendored
Normal 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
BIN
vendor/oban_met/.hex
vendored
Normal file
Binary file not shown.
113
vendor/oban_met/CHANGELOG.md
vendored
Normal file
113
vendor/oban_met/CHANGELOG.md
vendored
Normal 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
202
vendor/oban_met/LICENSE.txt
vendored
Normal 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
192
vendor/oban_met/README.md
vendored
Normal 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
28
vendor/oban_met/hex_metadata.config
vendored
Normal 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
325
vendor/oban_met/lib/met.ex
vendored
Normal 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
|
||||
83
vendor/oban_met/lib/oban/met/application.ex
vendored
Normal file
83
vendor/oban_met/lib/oban/met/application.ex
vendored
Normal 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
134
vendor/oban_met/lib/oban/met/cronitor.ex
vendored
Normal 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
180
vendor/oban_met/lib/oban/met/examiner.ex
vendored
Normal 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
157
vendor/oban_met/lib/oban/met/listener.ex
vendored
Normal 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
|
||||
103
vendor/oban_met/lib/oban/met/migration.ex
vendored
Normal file
103
vendor/oban_met/lib/oban/met/migration.ex
vendored
Normal 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
393
vendor/oban_met/lib/oban/met/recorder.ex
vendored
Normal 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
216
vendor/oban_met/lib/oban/met/reporter.ex
vendored
Normal 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
44
vendor/oban_met/lib/oban/met/value.ex
vendored
Normal 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
|
||||
141
vendor/oban_met/lib/oban/met/values/gauge.ex
vendored
Normal file
141
vendor/oban_met/lib/oban/met/values/gauge.ex
vendored
Normal 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
|
||||
186
vendor/oban_met/lib/oban/met/values/sketch.ex
vendored
Normal file
186
vendor/oban_met/lib/oban/met/values/sketch.ex
vendored
Normal 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
131
vendor/oban_met/mix.exs
vendored
Normal 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
14
vendor/oban_web/.formatter.exs
vendored
Normal 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
BIN
vendor/oban_web/.hex
vendored
Normal file
Binary file not shown.
237
vendor/oban_web/CHANGELOG.md
vendored
Normal file
237
vendor/oban_web/CHANGELOG.md
vendored
Normal 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
201
vendor/oban_web/LICENSE.txt
vendored
Normal 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
172
vendor/oban_web/README.md
vendored
Normal 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
179
vendor/oban_web/hex_metadata.config
vendored
Normal 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">>]}.
|
||||
134
vendor/oban_web/lib/mix/tasks/oban_web.install.ex
vendored
Normal file
134
vendor/oban_web/lib/mix/tasks/oban_web.install.ex
vendored
Normal 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
53
vendor/oban_web/lib/oban/web.ex
vendored
Normal 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
|
||||
12
vendor/oban_web/lib/oban/web/application.ex
vendored
Normal file
12
vendor/oban_web/lib/oban/web/application.ex
vendored
Normal 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
97
vendor/oban_web/lib/oban/web/assets.ex
vendored
Normal 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
|
||||
30
vendor/oban_web/lib/oban/web/authentication.ex
vendored
Normal file
30
vendor/oban_web/lib/oban/web/authentication.ex
vendored
Normal 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
53
vendor/oban_web/lib/oban/web/cache.ex
vendored
Normal 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
93
vendor/oban_web/lib/oban/web/colors.ex
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
defmodule Oban.Web.Colors do
|
||||
@moduledoc false
|
||||
|
||||
# Hex colors for SVGs and inline styles (must match assets/js/lib/colors.js)
|
||||
@hex %{
|
||||
blue: "#60a5fa",
|
||||
cyan: "#22d3ee",
|
||||
emerald: "#34d399",
|
||||
gray: "#9ca3af",
|
||||
indigo: "#818cf8",
|
||||
rose: "#fb7185",
|
||||
violet: "#a78bfa",
|
||||
yellow: "#facc15"
|
||||
}
|
||||
|
||||
# State to color name mapping
|
||||
@state_color_names %{
|
||||
"suspended" => :gray,
|
||||
"scheduled" => :indigo,
|
||||
"available" => :blue,
|
||||
"retryable" => :yellow,
|
||||
"executing" => :emerald,
|
||||
"completed" => :cyan,
|
||||
"cancelled" => :violet,
|
||||
"discarded" => :rose
|
||||
}
|
||||
|
||||
# Tailwind classes per state for templates
|
||||
@state_classes %{
|
||||
"suspended" => {"border-gray-400", "bg-gray-400/10", "text-gray-600 dark:text-gray-400"},
|
||||
"scheduled" =>
|
||||
{"border-indigo-400", "bg-indigo-400/10", "text-indigo-600 dark:text-indigo-400"},
|
||||
"available" => {"border-blue-400", "bg-blue-400/10", "text-blue-600 dark:text-blue-400"},
|
||||
"retryable" =>
|
||||
{"border-yellow-400", "bg-yellow-400/10", "text-yellow-600 dark:text-yellow-400"},
|
||||
"executing" =>
|
||||
{"border-emerald-400", "bg-emerald-400/10", "text-emerald-600 dark:text-emerald-400"},
|
||||
"completed" => {"border-cyan-400", "bg-cyan-400/10", "text-cyan-600 dark:text-cyan-400"},
|
||||
"cancelled" =>
|
||||
{"border-violet-400", "bg-violet-400/10", "text-violet-600 dark:text-violet-400"},
|
||||
"discarded" => {"border-rose-400", "bg-rose-400/10", "text-rose-600 dark:text-rose-400"}
|
||||
}
|
||||
|
||||
@inactive_classes {
|
||||
"border-gray-300 dark:border-gray-600",
|
||||
"bg-gray-100 dark:bg-gray-800",
|
||||
"text-gray-400 dark:text-gray-500"
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns the hex color for a state (for SVGs and inline styles).
|
||||
"""
|
||||
def state_hex(state) when is_binary(state) do
|
||||
color_name = Map.get(@state_color_names, state, :gray)
|
||||
Map.fetch!(@hex, color_name)
|
||||
end
|
||||
|
||||
def state_hex(_state), do: Map.fetch!(@hex, :gray)
|
||||
|
||||
@doc """
|
||||
Returns Tailwind classes for a state: {border, background, text}.
|
||||
"""
|
||||
def state_classes(state) when is_binary(state) do
|
||||
Map.get(@state_classes, state, @inactive_classes)
|
||||
end
|
||||
|
||||
def state_classes(_state), do: @inactive_classes
|
||||
|
||||
@doc """
|
||||
Returns Tailwind classes for inactive state.
|
||||
"""
|
||||
def inactive_classes, do: @inactive_classes
|
||||
|
||||
@doc """
|
||||
Returns the background class for a state (e.g., "bg-cyan-400").
|
||||
"""
|
||||
def state_bg_class(state) when is_binary(state) do
|
||||
color_name = Map.get(@state_color_names, state, :gray)
|
||||
"bg-#{color_name}-400"
|
||||
end
|
||||
|
||||
def state_bg_class(_state), do: "bg-gray-400"
|
||||
|
||||
@doc """
|
||||
Returns the text class for a state (e.g., "text-cyan-400").
|
||||
"""
|
||||
def state_text_class(state) when is_binary(state) do
|
||||
color_name = Map.get(@state_color_names, state, :gray)
|
||||
"text-#{color_name}-400"
|
||||
end
|
||||
|
||||
def state_text_class(_state), do: "text-gray-400"
|
||||
end
|
||||
424
vendor/oban_web/lib/oban/web/components/core.ex
vendored
Normal file
424
vendor/oban_web/lib/oban/web/components/core.ex
vendored
Normal 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
|
||||
192
vendor/oban_web/lib/oban/web/components/form_components.ex
vendored
Normal file
192
vendor/oban_web/lib/oban/web/components/form_components.ex
vendored
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
defmodule Oban.Web.FormComponents do
|
||||
@moduledoc false
|
||||
|
||||
use Oban.Web, :html
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :name, :string, required: true
|
||||
attr :value, :any, default: nil
|
||||
attr :type, :string, default: "text"
|
||||
attr :colspan, :string, default: nil
|
||||
attr :placeholder, :string, default: nil
|
||||
attr :required, :boolean, default: false
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :hint, :string, default: nil
|
||||
attr :rows, :integer, default: 2
|
||||
|
||||
def form_field(assigns) do
|
||||
~H"""
|
||||
<div class={@colspan}>
|
||||
<label
|
||||
for={@name}
|
||||
class="flex items-center gap-1 font-medium text-sm text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
{@label}
|
||||
<span :if={@required} class="text-rose-500">*</span>
|
||||
<span
|
||||
:if={@hint}
|
||||
id={"#{@name}-hint"}
|
||||
data-title={@hint}
|
||||
phx-hook="Tippy"
|
||||
class="translate-y-px"
|
||||
>
|
||||
<Icons.info_circle class="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
</span>
|
||||
</label>
|
||||
<%= if @type == "textarea" do %>
|
||||
<textarea
|
||||
id={@name}
|
||||
name={@name}
|
||||
rows={@rows}
|
||||
disabled={@disabled}
|
||||
placeholder={@placeholder}
|
||||
class="block w-full font-mono text-sm shadow-sm border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-md focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
|
||||
>{@value}</textarea>
|
||||
<% else %>
|
||||
<input
|
||||
type={@type}
|
||||
id={@name}
|
||||
name={@name}
|
||||
value={@value}
|
||||
disabled={@disabled}
|
||||
placeholder={@placeholder}
|
||||
required={@required}
|
||||
class="block w-full font-mono text-sm shadow-sm border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-md focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :name, :string, required: true
|
||||
attr :value, :any, default: nil
|
||||
attr :options, :list, required: true
|
||||
attr :disabled, :boolean, default: false
|
||||
|
||||
def select_field(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<label for={@name} class="block font-medium text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
{@label}
|
||||
</label>
|
||||
<select
|
||||
id={@name}
|
||||
name={@name}
|
||||
disabled={@disabled}
|
||||
class="block w-full font-mono text-sm shadow-sm border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-md focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<option :for={{label, val} <- @options} value={val} selected={val == @value}>
|
||||
{label}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :name, :string, required: true
|
||||
attr :checked, :boolean, default: false
|
||||
attr :disabled, :boolean, default: false
|
||||
attr :hint, :string, default: nil
|
||||
attr :colspan, :string, default: nil
|
||||
|
||||
def checkbox_field(assigns) do
|
||||
~H"""
|
||||
<div class={[@colspan, "group"]}>
|
||||
<input type="hidden" name={@name} value="false" />
|
||||
<label
|
||||
for={@name}
|
||||
class="flex items-center font-medium text-sm text-gray-700 dark:text-gray-300 cursor-pointer group-has-[:disabled]:opacity-50 group-has-[:disabled]:cursor-not-allowed"
|
||||
>
|
||||
<div class="relative mr-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@name}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
disabled={@disabled}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<span class="block w-4 h-4 rounded border border-gray-300 dark:border-gray-600 peer-checked:border-blue-500 peer-checked:bg-blue-500" />
|
||||
<Icons.check class="w-3 h-3 text-white absolute top-0.5 left-0.5 hidden peer-checked:block" />
|
||||
</div>
|
||||
{@label}
|
||||
<span
|
||||
:if={@hint}
|
||||
id={"#{@name}-hint"}
|
||||
data-title={@hint}
|
||||
phx-hook="Tippy"
|
||||
class="ml-1 translate-y-px"
|
||||
>
|
||||
<Icons.info_circle class="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Form Parsing Helpers
|
||||
|
||||
@doc """
|
||||
Parses and trims a string, returning nil for empty values.
|
||||
"""
|
||||
def parse_string(nil), do: nil
|
||||
def parse_string(""), do: nil
|
||||
def parse_string(str), do: String.trim(str)
|
||||
|
||||
@doc """
|
||||
Parses a string to an integer, returning nil for empty or invalid values.
|
||||
"""
|
||||
def parse_int(nil), do: nil
|
||||
def parse_int(""), do: nil
|
||||
|
||||
def parse_int(str) when is_binary(str) do
|
||||
case Integer.parse(str) do
|
||||
{num, ""} -> num
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses a comma-separated string into a list of trimmed tags. Returns nil for empty input.
|
||||
"""
|
||||
def parse_tags(nil), do: nil
|
||||
def parse_tags(""), do: nil
|
||||
|
||||
def parse_tags(str) when is_binary(str) do
|
||||
str
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|> case do
|
||||
[] -> nil
|
||||
tags -> tags
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses a JSON string into a map, returning nil for empty or invalid values.
|
||||
"""
|
||||
def parse_json(nil), do: nil
|
||||
def parse_json(""), do: nil
|
||||
|
||||
def parse_json(str) when is_binary(str) do
|
||||
case Oban.JSON.decode!(str) do
|
||||
map when is_map(map) -> map
|
||||
_ -> nil
|
||||
end
|
||||
rescue
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds queue options from a list of queue structs.
|
||||
"""
|
||||
def queue_options(queues) do
|
||||
queues
|
||||
|> Enum.map(fn %{name: name} -> {name, name} end)
|
||||
|> Enum.sort_by(&elem(&1, 0))
|
||||
end
|
||||
end
|
||||
162
vendor/oban_web/lib/oban/web/components/icons.ex
vendored
Normal file
162
vendor/oban_web/lib/oban/web/components/icons.ex
vendored
Normal 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
|
||||
210
vendor/oban_web/lib/oban/web/components/layouts.ex
vendored
Normal file
210
vendor/oban_web/lib/oban/web/components/layouts.ex
vendored
Normal file
File diff suppressed because one or more lines are too long
32
vendor/oban_web/lib/oban/web/components/layouts/live.html.heex
vendored
Normal file
32
vendor/oban_web/lib/oban/web/components/layouts/live.html.heex
vendored
Normal 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>
|
||||
42
vendor/oban_web/lib/oban/web/components/layouts/root.html.heex
vendored
Normal file
42
vendor/oban_web/lib/oban/web/components/layouts/root.html.heex
vendored
Normal 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>
|
||||
123
vendor/oban_web/lib/oban/web/components/sidebar_components.ex
vendored
Normal file
123
vendor/oban_web/lib/oban/web/components/sidebar_components.ex
vendored
Normal 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
|
||||
84
vendor/oban_web/lib/oban/web/components/sort.ex
vendored
Normal file
84
vendor/oban_web/lib/oban/web/components/sort.ex
vendored
Normal 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
16
vendor/oban_web/lib/oban/web/cron.ex
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
defmodule Oban.Web.Cron do
|
||||
@moduledoc false
|
||||
|
||||
defstruct [
|
||||
:name,
|
||||
:expression,
|
||||
:worker,
|
||||
:opts,
|
||||
:next_at,
|
||||
:last_at,
|
||||
:last_state,
|
||||
dynamic?: false,
|
||||
paused?: false,
|
||||
history: []
|
||||
]
|
||||
end
|
||||
348
vendor/oban_web/lib/oban/web/cron_expr.ex
vendored
Normal file
348
vendor/oban_web/lib/oban/web/cron_expr.ex
vendored
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
defmodule Oban.Web.CronExpr do
|
||||
@moduledoc false
|
||||
|
||||
@days_of_week_names %{
|
||||
0 => "Sunday",
|
||||
1 => "Monday",
|
||||
2 => "Tuesday",
|
||||
3 => "Wednesday",
|
||||
4 => "Thursday",
|
||||
5 => "Friday",
|
||||
6 => "Saturday"
|
||||
}
|
||||
|
||||
@days_of_week_translations %{
|
||||
"SUN" => 0,
|
||||
"MON" => 1,
|
||||
"TUE" => 2,
|
||||
"WED" => 3,
|
||||
"THU" => 4,
|
||||
"FRI" => 5,
|
||||
"SAT" => 6
|
||||
}
|
||||
|
||||
@doc """
|
||||
Convert a cron expression into a human-readable description.
|
||||
|
||||
Returns a friendly string for common patterns, or nil for complex patterns
|
||||
that don't match known templates.
|
||||
"""
|
||||
def describe(expression) when is_binary(expression) do
|
||||
case expression do
|
||||
"@yearly" -> "Yearly on January 1st"
|
||||
"@annually" -> "Yearly on January 1st"
|
||||
"@monthly" -> "Monthly on the 1st"
|
||||
"@weekly" -> "Weekly on Sunday"
|
||||
"@daily" -> "Daily at midnight"
|
||||
"@midnight" -> "Daily at midnight"
|
||||
"@hourly" -> "Every hour"
|
||||
"@reboot" -> "At system reboot"
|
||||
_ -> describe_parsed(expression)
|
||||
end
|
||||
end
|
||||
|
||||
def describe(_), do: nil
|
||||
|
||||
defp describe_parsed(expression) do
|
||||
with [min, hrs, dom, "*", dow] <- String.split(expression, " ", parts: 5),
|
||||
{:ok, parsed_min} <- parse_field(min),
|
||||
{:ok, parsed_hrs} <- parse_field(hrs),
|
||||
{:ok, parsed_dom} <- parse_field(dom),
|
||||
{:ok, parsed_dow} <- parse_field(dow, @days_of_week_translations) do
|
||||
combine_description(parsed_min, parsed_hrs, parsed_dom, parsed_dow)
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Field Parsing
|
||||
|
||||
defp parse_field(field, translations \\ %{}) do
|
||||
parts =
|
||||
field
|
||||
|> String.split(",")
|
||||
|> Enum.map(&parse_part(&1, translations))
|
||||
|
||||
if Enum.any?(parts, &(&1 == :error)) do
|
||||
:error
|
||||
else
|
||||
{:ok, parts}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_part("*", _translations), do: :wildcard
|
||||
|
||||
defp parse_part("*/" <> step, _translations) do
|
||||
case Integer.parse(step) do
|
||||
{num, ""} when num > 0 -> {:step, num}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_part(part, translations) do
|
||||
if String.contains?(part, "-") do
|
||||
parse_range(part, translations)
|
||||
else
|
||||
parse_value(part, translations)
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_range(part, translations) do
|
||||
with [start_str, end_str] <- String.split(part, "-", parts: 2),
|
||||
{:ok, start_val} <- translate_or_parse(start_str, translations),
|
||||
{:ok, end_val} <- translate_or_parse(end_str, translations) do
|
||||
{:range, start_val, end_val}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_value(part, translations) do
|
||||
case translate_or_parse(part, translations) do
|
||||
{:ok, val} -> {:value, val}
|
||||
:error -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp translate_or_parse(str, translations) do
|
||||
upper = String.upcase(str)
|
||||
|
||||
with :error <- Map.fetch(translations, upper) do
|
||||
case Integer.parse(str) do
|
||||
{num, ""} -> {:ok, num}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Description Combination
|
||||
|
||||
defp combine_description(min, hrs, dom, dow) do
|
||||
if dom == [:wildcard] and dow == [:wildcard] do
|
||||
describe_time(min, hrs)
|
||||
else
|
||||
combine_date_time(min, hrs, dom, dow)
|
||||
end
|
||||
end
|
||||
|
||||
defp describe_time([:wildcard], [:wildcard]), do: "Every minute"
|
||||
defp describe_time([{:step, 1}], [:wildcard]), do: "Every minute"
|
||||
defp describe_time([{:step, step}], [:wildcard]), do: "Every #{step} minutes"
|
||||
defp describe_time([{:value, 0}], [:wildcard]), do: "Every hour"
|
||||
defp describe_time([{:value, 0}], [{:step, 1}]), do: "Every hour"
|
||||
defp describe_time([{:value, 0}], [{:step, step}]), do: "Every #{step} hours"
|
||||
|
||||
defp describe_time([{:value, min}], [{:step, step}]) when min in 0..59 do
|
||||
"Every #{step} hours at :#{String.pad_leading("#{min}", 2, "0")}"
|
||||
end
|
||||
|
||||
defp describe_time([{:value, 0}], [{:value, 0}]), do: "Daily at midnight"
|
||||
defp describe_time([{:value, 0}], [{:value, 12}]), do: "Daily at noon"
|
||||
|
||||
defp describe_time([{:value, min}], [{:value, hr}]) when min in 0..59 and hr in 0..23 do
|
||||
"Daily at #{format_time_24h(hr, min)}"
|
||||
end
|
||||
|
||||
defp describe_time([{:value, min}], hours) when is_list(hours) and min in 0..59 do
|
||||
times =
|
||||
Enum.map(hours, fn
|
||||
{:value, hr} when hr in 0..23 -> format_time_24h(hr, min)
|
||||
_ -> nil
|
||||
end)
|
||||
|
||||
if Enum.any?(times, &is_nil/1) do
|
||||
nil
|
||||
else
|
||||
"Daily at #{format_list(times)}"
|
||||
end
|
||||
end
|
||||
|
||||
defp describe_time(_, _), do: nil
|
||||
|
||||
defp combine_date_time(min, hrs, dom, dow) do
|
||||
time = extract_time(min, hrs)
|
||||
date_desc = describe_date(dom, dow)
|
||||
|
||||
case {time, date_desc} do
|
||||
{nil, _} -> nil
|
||||
{_, nil} -> nil
|
||||
{{0, 0}, desc} -> "#{desc} at 0:00"
|
||||
{{hrs, min}, desc} -> "#{desc} at #{format_time_24h(hrs, min)}"
|
||||
end
|
||||
end
|
||||
|
||||
defp extract_time([{:value, min}], [{:value, hrs}]) when min in 0..59 and hrs in 0..23 do
|
||||
{hrs, min}
|
||||
end
|
||||
|
||||
defp extract_time(_, _), do: nil
|
||||
|
||||
# Date Description (DOM and DOW combination)
|
||||
|
||||
defp describe_date([:wildcard], dow) do
|
||||
describe_dow(dow)
|
||||
end
|
||||
|
||||
defp describe_date(dom, [:wildcard]) do
|
||||
describe_dom(dom)
|
||||
end
|
||||
|
||||
defp describe_date(dom, dow) do
|
||||
describe_combined_dom_dow(dom, dow)
|
||||
end
|
||||
|
||||
# Day of Month Description
|
||||
|
||||
defp describe_dom([{:value, day}]) when day in 1..31 do
|
||||
"Monthly on the #{ordinal(day)}"
|
||||
end
|
||||
|
||||
defp describe_dom(parts) when is_list(parts) do
|
||||
values = expand_dom_parts(parts)
|
||||
|
||||
case excluded_dom(values) do
|
||||
{:ok, day} ->
|
||||
"Daily except the #{ordinal(day)}"
|
||||
|
||||
:error ->
|
||||
days = extract_dom_values(parts)
|
||||
|
||||
if days != [] do
|
||||
"On the #{format_list(Enum.map(days, &ordinal/1))}"
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp describe_dom(_), do: nil
|
||||
|
||||
defp excluded_dom(values) do
|
||||
case Enum.to_list(1..31) -- values do
|
||||
[excluded] -> {:ok, excluded}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp expand_dom_parts(parts) do
|
||||
parts
|
||||
|> Enum.flat_map(fn
|
||||
{:value, val} -> [val]
|
||||
{:range, start_val, end_val} -> Enum.to_list(start_val..end_val)
|
||||
_ -> []
|
||||
end)
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
defp extract_dom_values(parts) do
|
||||
Enum.flat_map(parts, fn
|
||||
{:value, val} when val in 1..31 ->
|
||||
[val]
|
||||
|
||||
{:range, start_val, end_val} when start_val in 1..31 and end_val in 1..31 ->
|
||||
Enum.to_list(start_val..end_val)
|
||||
|
||||
_ ->
|
||||
[]
|
||||
end)
|
||||
end
|
||||
|
||||
# Day of Week Description
|
||||
|
||||
defp describe_dow([{:value, day}]) when day in 0..7 do
|
||||
"Weekly on #{day_name(normalize_dow(day))}"
|
||||
end
|
||||
|
||||
defp describe_dow(parts) when is_list(parts) do
|
||||
values = expand_dow_parts(parts)
|
||||
|
||||
cond do
|
||||
values == [1, 2, 3, 4, 5] ->
|
||||
"Weekdays"
|
||||
|
||||
values == [0, 6] ->
|
||||
"Weekends"
|
||||
|
||||
length(values) == 6 ->
|
||||
{:ok, day} = excluded_dow(values)
|
||||
|
||||
"Daily except #{day_name(day)}s"
|
||||
|
||||
values != [] ->
|
||||
"On #{format_list(Enum.map(values, &day_name/1))}"
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp describe_dow(_), do: nil
|
||||
|
||||
defp expand_dow_parts(parts) do
|
||||
parts
|
||||
|> Enum.flat_map(fn
|
||||
{:value, val} -> [normalize_dow(val)]
|
||||
{:range, start_val, end_val} -> Enum.map(start_val..end_val, &normalize_dow/1)
|
||||
_ -> []
|
||||
end)
|
||||
|> Enum.uniq()
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
defp normalize_dow(7), do: 0
|
||||
defp normalize_dow(day), do: day
|
||||
|
||||
defp excluded_dow(values) do
|
||||
case Enum.to_list(0..6) -- values do
|
||||
[excluded] -> {:ok, excluded}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
# Combined DOM and DOW description
|
||||
|
||||
defp describe_combined_dom_dow(dom, dow) do
|
||||
dom_values = expand_dom_parts(dom)
|
||||
dow_values = expand_dow_parts(dow)
|
||||
|
||||
case {dom_values, excluded_dom(dom_values), dow_values, excluded_dow(dow_values)} do
|
||||
{[dom_day], _, [dow_day], _} ->
|
||||
"The #{ordinal(dom_day)}, only on #{day_name(dow_day)}s"
|
||||
|
||||
{[dom_day], _, _, {:ok, excluded}} ->
|
||||
"The #{ordinal(dom_day)}, except #{day_name(excluded)}s"
|
||||
|
||||
{_, {:ok, excluded}, [dow_day], _} ->
|
||||
"#{day_name(dow_day)}s, except the #{ordinal(excluded)}"
|
||||
|
||||
{_, {:ok, dom_excluded}, _, {:ok, dow_excluded}} ->
|
||||
"Daily except the #{ordinal(dom_excluded)} and #{day_name(dow_excluded)}s"
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp day_name(num), do: Map.get(@days_of_week_names, num, "Unknown")
|
||||
|
||||
defp format_time_24h(hr, min) do
|
||||
"#{hr}:#{String.pad_leading("#{min}", 2, "0")}"
|
||||
end
|
||||
|
||||
defp format_list([single]), do: single
|
||||
defp format_list([first, second]), do: "#{first} and #{second}"
|
||||
|
||||
defp format_list(items) when length(items) > 2 do
|
||||
{init, [last]} = Enum.split(items, -1)
|
||||
"#{Enum.join(init, ", ")}, and #{last}"
|
||||
end
|
||||
|
||||
defp format_list(_), do: nil
|
||||
|
||||
defp ordinal(num) when num in [1, 21, 31], do: "#{num}st"
|
||||
defp ordinal(num) when num in [2, 22], do: "#{num}nd"
|
||||
defp ordinal(num) when num in [3, 23], do: "#{num}rd"
|
||||
defp ordinal(num), do: "#{num}th"
|
||||
end
|
||||
252
vendor/oban_web/lib/oban/web/dashboard_live.ex
vendored
Normal file
252
vendor/oban_web/lib/oban/web/dashboard_live.ex
vendored
Normal 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
|
||||
5
vendor/oban_web/lib/oban/web/exceptions.ex
vendored
Normal file
5
vendor/oban_web/lib/oban/web/exceptions.ex
vendored
Normal 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
301
vendor/oban_web/lib/oban/web/helpers.ex
vendored
Normal 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
|
||||
24
vendor/oban_web/lib/oban/web/helpers/queue_helper.ex
vendored
Normal file
24
vendor/oban_web/lib/oban/web/helpers/queue_helper.ex
vendored
Normal 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
|
||||
59
vendor/oban_web/lib/oban/web/live/connectivity_component.ex
vendored
Normal file
59
vendor/oban_web/lib/oban/web/live/connectivity_component.ex
vendored
Normal 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
|
||||
450
vendor/oban_web/lib/oban/web/live/crons/detail_component.ex
vendored
Normal file
450
vendor/oban_web/lib/oban/web/live/crons/detail_component.ex
vendored
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
defmodule Oban.Web.Crons.DetailComponent do
|
||||
use Oban.Web, :live_component
|
||||
|
||||
import Oban.Web.Crons.Helpers, only: [state_icon: 1, maybe_to_unix: 1, show_name?: 1]
|
||||
import Oban.Web.FormComponents
|
||||
|
||||
alias Oban.Job
|
||||
alias Oban.Pro.Plugins.DynamicCron
|
||||
alias Oban.Web.{CronExpr, Timezones}
|
||||
|
||||
@compile {:no_warn_undefined, DynamicCron}
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="cron-details" phx-window-keydown="keydown" phx-target={@myself}>
|
||||
<div class="flex justify-between items-center px-3 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<.link
|
||||
patch={oban_path(:crons, @params)}
|
||||
id="back-link"
|
||||
class="flex items-center hover:text-blue-500"
|
||||
data-title="Back to crons"
|
||||
phx-hook="Tippy"
|
||||
>
|
||||
<Icons.arrow_left class="w-5 h-5" />
|
||||
<span class="text-lg font-bold ml-2">
|
||||
{@cron.worker}
|
||||
<span :if={show_name?(@cron)} class="font-normal text-gray-500 dark:text-gray-400">
|
||||
({@cron.name})
|
||||
</span>
|
||||
</span>
|
||||
</.link>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<Core.status_badge :if={@cron.dynamic?} icon="sparkles" label="Dynamic" />
|
||||
|
||||
<Core.icon_button
|
||||
id="run-now-button"
|
||||
icon="play_circle"
|
||||
label="Run Now"
|
||||
color="blue"
|
||||
tooltip="Insert a job for this cron immediately"
|
||||
phx-click="run-now"
|
||||
phx-target={@myself}
|
||||
/>
|
||||
|
||||
<Core.icon_button
|
||||
id="toggle-pause-button"
|
||||
icon={if @cron.paused?, do: "play_circle", else: "pause_circle"}
|
||||
label={if @cron.paused?, do: "Resume", else: "Pause"}
|
||||
color="yellow"
|
||||
tooltip={if @cron.paused?, do: "Resume scheduling jobs", else: "Pause scheduling jobs"}
|
||||
disabled={not @cron.dynamic?}
|
||||
phx-click="toggle-pause"
|
||||
phx-target={@myself}
|
||||
/>
|
||||
|
||||
<Core.icon_button
|
||||
id="delete-cron-button"
|
||||
icon="trash"
|
||||
label="Delete"
|
||||
color="red"
|
||||
tooltip="Delete this cron"
|
||||
disabled={not @cron.dynamic?}
|
||||
confirm="Are you sure you want to delete this cron?"
|
||||
phx-click="delete-cron"
|
||||
phx-target={@myself}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-6 px-3 py-6">
|
||||
<div class="col-span-2">
|
||||
<.history_chart cron={@cron} />
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<div class="flex justify-between mb-6 pr-6">
|
||||
<div class="flex flex-col">
|
||||
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Last Run
|
||||
</span>
|
||||
<span class="text-base text-gray-800 dark:text-gray-200">
|
||||
<span
|
||||
id="cron-last-time"
|
||||
data-timestamp={maybe_to_unix(@cron.last_at)}
|
||||
phx-hook="Relativize"
|
||||
phx-update="ignore"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Next Run
|
||||
</span>
|
||||
<span class="text-base text-gray-800 dark:text-gray-200">
|
||||
<span
|
||||
id="cron-next-time"
|
||||
data-timestamp={maybe_to_unix(@cron.next_at)}
|
||||
phx-hook="Relativize"
|
||||
phx-update="ignore"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Last Status
|
||||
</span>
|
||||
<div class="flex items-center space-x-1">
|
||||
<.state_icon state={@cron.last_state} paused={@cron.paused?} />
|
||||
<span class="text-base text-gray-800 dark:text-gray-200">
|
||||
{if @cron.paused?, do: "Paused", else: state_label(@cron.last_state)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Schedule
|
||||
</span>
|
||||
<span class="text-base text-gray-800 dark:text-gray-200">
|
||||
<code class="font-mono">{@cron.expression}</code>
|
||||
<span
|
||||
:if={CronExpr.describe(@cron.expression)}
|
||||
class="ml-2 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
({CronExpr.describe(@cron.expression)})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3 py-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 class="flex font-semibold mb-3 space-x-2 text-gray-400">
|
||||
<Icons.pencil_square />
|
||||
<span>Edit Configuration</span>
|
||||
</h3>
|
||||
|
||||
<fieldset disabled={not @cron.dynamic?}>
|
||||
<form
|
||||
id="cron-form"
|
||||
class="grid grid-cols-4 gap-4 bg-gray-50 dark:bg-gray-800 rounded-md p-4"
|
||||
phx-change="form-change"
|
||||
phx-submit="save-cron"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.form_field label="Schedule" name="expression" value={@cron.expression} />
|
||||
|
||||
<.form_field label="Worker" name="worker" value={@cron.worker} />
|
||||
|
||||
<.select_field
|
||||
label="Queue"
|
||||
name="queue"
|
||||
value={get_opt(@cron, "queue") || "default"}
|
||||
options={queue_options(@queues)}
|
||||
/>
|
||||
|
||||
<.select_field
|
||||
label="Timezone"
|
||||
name="timezone"
|
||||
value={get_opt(@cron, "timezone") || "Etc/UTC"}
|
||||
options={Timezones.options_with_blank()}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<.form_field
|
||||
label="Priority"
|
||||
name="priority"
|
||||
value={get_opt(@cron, "priority")}
|
||||
type="number"
|
||||
placeholder="0"
|
||||
/>
|
||||
|
||||
<.form_field
|
||||
label="Max Attempts"
|
||||
name="max_attempts"
|
||||
value={get_opt(@cron, "max_attempts")}
|
||||
type="number"
|
||||
placeholder="20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.form_field label="Tags" name="tags" value={format_tags(@cron)} placeholder="tag1, tag2" />
|
||||
|
||||
<.form_field
|
||||
label="Args"
|
||||
name="args"
|
||||
value={format_args(@cron)}
|
||||
colspan="col-span-2"
|
||||
type="textarea"
|
||||
placeholder="{}"
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
<div class="col-span-2 flex items-end gap-4 p-3 bg-violet-50 dark:bg-violet-950/30 rounded-md ring-1 ring-violet-200 dark:ring-violet-800">
|
||||
<.form_field
|
||||
label="Name"
|
||||
name="name"
|
||||
value={@cron.name}
|
||||
disabled={not @cron.dynamic?}
|
||||
hint="Changing the name will reset cron history"
|
||||
colspan="flex-1"
|
||||
/>
|
||||
|
||||
<div class="pb-2 pr-6">
|
||||
<.checkbox_field
|
||||
label="Guaranteed"
|
||||
name="guaranteed"
|
||||
checked={get_opt(@cron, "guaranteed") == true}
|
||||
disabled={not @cron.dynamic?}
|
||||
hint="Ensures a job is inserted even if the scheduled time passed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 flex justify-end items-center gap-3 pt-6">
|
||||
<a
|
||||
:if={not @cron.dynamic?}
|
||||
rel="static-blocker"
|
||||
href="https://oban.pro/docs/pro/Oban.Pro.Plugins.DynamicCron.html"
|
||||
target="_blank"
|
||||
class="text-xs text-gray-500 dark:text-gray-400 hover:underline"
|
||||
>
|
||||
Editing requires DynamicCron
|
||||
<Icons.arrow_top_right_on_square class="w-3 h-3 inline-block" />
|
||||
</a>
|
||||
<.save_button disabled={not @changed?} />
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :cron, :any, required: true
|
||||
|
||||
defp history_chart(assigns) do
|
||||
~H"""
|
||||
<div class="group relative">
|
||||
<div
|
||||
id="cron-chart"
|
||||
class="h-48 bg-gray-50 dark:bg-gray-800 rounded-md p-4"
|
||||
phx-hook="CronChart"
|
||||
phx-update="ignore"
|
||||
>
|
||||
</div>
|
||||
<.link
|
||||
navigate={oban_path(:jobs, %{meta: [["cron_name"], @cron.name], state: "completed"})}
|
||||
class="absolute right-4 top-4 flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900 dark:hover:text-blue-300 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
View all jobs <Icons.arrow_right class="w-3 h-3" />
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :disabled, :boolean, default: false
|
||||
|
||||
defp save_button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
type="submit"
|
||||
disabled={@disabled}
|
||||
class="px-6 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Update Entry
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
# Callbacks
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def update(assigns, socket) do
|
||||
chart_data = Enum.map(assigns.history, &chart_point/1)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:changed?, fn -> false end)
|
||||
|> push_event("cron-history", %{history: chart_data})
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp chart_point(job) do
|
||||
timestamp =
|
||||
(job.finished_at || job.attempted_at || job.scheduled_at)
|
||||
|> DateTime.from_naive!("Etc/UTC")
|
||||
|> DateTime.to_unix(:millisecond)
|
||||
|
||||
duration =
|
||||
case {job.attempted_at, job.finished_at} do
|
||||
{nil, _} ->
|
||||
0
|
||||
|
||||
{attempted_at, nil} ->
|
||||
NaiveDateTime.diff(NaiveDateTime.utc_now(), attempted_at, :millisecond)
|
||||
|
||||
{attempted_at, finished_at} ->
|
||||
NaiveDateTime.diff(finished_at, attempted_at, :millisecond)
|
||||
end
|
||||
|
||||
%{timestamp: timestamp, duration: duration, state: job.state}
|
||||
end
|
||||
|
||||
# Handlers
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def handle_event("run-now", _params, socket) do
|
||||
enforce_access!(:insert_jobs, socket.assigns.access)
|
||||
|
||||
%{cron: cron, conf: conf} = socket.assigns
|
||||
|
||||
args = Map.get(cron.opts, "args", %{})
|
||||
|
||||
opts =
|
||||
cron.opts
|
||||
|> Map.take(~w(max_attempts priority queue tags))
|
||||
|> Keyword.new(fn {key, val} -> {String.to_existing_atom(key), val} end)
|
||||
|> Keyword.put(:worker, cron.worker)
|
||||
|> Keyword.put(:meta, %{cron: true, cron_expr: cron.expression, cron_name: cron.name})
|
||||
|
||||
changeset = Job.new(args, opts)
|
||||
|
||||
case Oban.insert(conf.name, changeset) do
|
||||
{:ok, _job} ->
|
||||
send(self(), :refresh)
|
||||
send(self(), {:flash, :info, "Job inserted for #{cron.worker}"})
|
||||
|
||||
{:error, _reason} ->
|
||||
send(self(), {:flash, :error, "Failed to insert job"})
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("toggle-pause", _params, socket) do
|
||||
enforce_access!(:pause_crons, socket.assigns.access)
|
||||
|
||||
%{cron: cron, conf: conf} = socket.assigns
|
||||
|
||||
paused? = not cron.paused?
|
||||
|
||||
DynamicCron.update(conf.name, cron.name, paused: paused?)
|
||||
|
||||
{:noreply, assign(socket, cron: %{cron | paused?: paused?})}
|
||||
end
|
||||
|
||||
def handle_event("delete-cron", _params, socket) do
|
||||
enforce_access!(:delete_crons, socket.assigns.access)
|
||||
|
||||
%{cron: cron, conf: conf, params: params} = socket.assigns
|
||||
|
||||
case DynamicCron.delete(conf.name, cron.name) do
|
||||
{:ok, _deleted} ->
|
||||
send(self(), {:flash, :info, "Deleted cron #{cron.name}"})
|
||||
{:noreply, push_patch(socket, to: oban_path(:crons, params))}
|
||||
|
||||
{:error, _reason} ->
|
||||
send(self(), {:flash, :error, "Failed to delete cron"})
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("form-change", params, socket) do
|
||||
changed? =
|
||||
params
|
||||
|> parse_form_params(socket.assigns.cron)
|
||||
|> Enum.any?(fn {_key, val} -> not is_nil(val) end)
|
||||
|
||||
{:noreply, assign(socket, changed?: changed?)}
|
||||
end
|
||||
|
||||
def handle_event("keydown", %{"key" => "Escape"}, socket) do
|
||||
{:noreply, push_patch(socket, to: oban_path(:crons, socket.assigns.params))}
|
||||
end
|
||||
|
||||
def handle_event("keydown", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("save-cron", params, socket) do
|
||||
enforce_access!(:update_crons, socket.assigns.access)
|
||||
|
||||
%{cron: cron, conf: conf} = socket.assigns
|
||||
|
||||
opts =
|
||||
params
|
||||
|> parse_form_params(cron)
|
||||
|> Enum.reject(fn {_key, val} -> is_nil(val) end)
|
||||
|
||||
case DynamicCron.update(conf.name, cron.name, opts) do
|
||||
{:ok, _entry} ->
|
||||
send(self(), :refresh)
|
||||
send(self(), {:flash, :info, "Cron configuration updated"})
|
||||
{:noreply, assign(socket, changed?: false)}
|
||||
|
||||
{:error, _reason} ->
|
||||
send(self(), {:flash, :error, "Failed to update cron configuration"})
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# Helpers
|
||||
|
||||
defp parse_form_params(params, cron) do
|
||||
[
|
||||
name: new_val?(parse_string(params["name"]), cron.name),
|
||||
worker: new_val?(parse_string(params["worker"]), cron.worker),
|
||||
expression: new_val?(params["expression"], cron.expression),
|
||||
queue: new_val?(parse_string(params["queue"]), get_opt(cron, "queue"), "default"),
|
||||
timezone: new_val?(parse_string(params["timezone"]), get_opt(cron, "timezone"), "Etc/UTC"),
|
||||
priority: new_val?(parse_int(params["priority"]), get_opt(cron, "priority")),
|
||||
max_attempts: new_val?(parse_int(params["max_attempts"]), get_opt(cron, "max_attempts")),
|
||||
guaranteed: new_val?(params["guaranteed"] == "true", get_opt(cron, "guaranteed") == true),
|
||||
tags: new_val?(parse_tags(params["tags"]), get_opt(cron, "tags")),
|
||||
args: new_val?(parse_json(params["args"]), get_opt(cron, "args"))
|
||||
]
|
||||
end
|
||||
|
||||
defp new_val?(new_val, current_val, default \\ nil)
|
||||
defp new_val?(nil, _current, _default), do: nil
|
||||
defp new_val?("", _current, _default), do: nil
|
||||
defp new_val?(val, val, _default), do: nil
|
||||
defp new_val?(val, nil, val), do: nil
|
||||
defp new_val?(val, _current, _default), do: val
|
||||
|
||||
defp get_opt(%{opts: opts}, key) do
|
||||
Map.get(opts, key)
|
||||
end
|
||||
|
||||
defp format_tags(%{opts: %{"tags" => tags}}) when is_list(tags), do: Enum.join(tags, ", ")
|
||||
defp format_tags(_), do: nil
|
||||
|
||||
defp format_args(%{opts: %{"args" => args}}) when is_map(args), do: Oban.JSON.encode!(args)
|
||||
defp format_args(_), do: nil
|
||||
|
||||
defp state_label(nil), do: "Unknown"
|
||||
defp state_label(state), do: String.capitalize(state)
|
||||
end
|
||||
56
vendor/oban_web/lib/oban/web/live/crons/helpers.ex
vendored
Normal file
56
vendor/oban_web/lib/oban/web/live/crons/helpers.ex
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
defmodule Oban.Web.Crons.Helpers do
|
||||
@moduledoc false
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
alias Oban.Web.Colors
|
||||
alias Oban.Web.Components.Icons
|
||||
|
||||
attr :state, :string, required: true
|
||||
attr :paused, :boolean, default: false
|
||||
|
||||
def state_icon(%{paused: true} = assigns) do
|
||||
~H"""
|
||||
<Icons.pause_circle class="w-5 h-5 text-gray-400" />
|
||||
"""
|
||||
end
|
||||
|
||||
def state_icon(assigns) do
|
||||
assigns = assign(assigns, :color_class, Colors.state_text_class(assigns.state))
|
||||
|
||||
~H"""
|
||||
<%= case @state do %>
|
||||
<% "available" -> %>
|
||||
<Icons.ellipsis_horizontal_circle class={["w-5 h-5", @color_class]} />
|
||||
<% "cancelled" -> %>
|
||||
<Icons.x_circle class={["w-5 h-5", @color_class]} />
|
||||
<% "completed" -> %>
|
||||
<Icons.check_circle class={["w-5 h-5", @color_class]} />
|
||||
<% "discarded" -> %>
|
||||
<Icons.exclamation_circle class={["w-5 h-5", @color_class]} />
|
||||
<% "executing" -> %>
|
||||
<Icons.play_circle class={["w-5 h-5", @color_class]} />
|
||||
<% "retryable" -> %>
|
||||
<Icons.arrow_path class={["w-5 h-5", @color_class]} />
|
||||
<% "scheduled" -> %>
|
||||
<Icons.clock class={["w-5 h-5", @color_class]} />
|
||||
<% _ -> %>
|
||||
<Icons.minus_circle class="w-5 h-5 text-gray-400" />
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts a NaiveDateTime to unix milliseconds, returning empty string for nil.
|
||||
"""
|
||||
def maybe_to_unix(nil), do: ""
|
||||
|
||||
def maybe_to_unix(timestamp) do
|
||||
timestamp
|
||||
|> DateTime.from_naive!("Etc/UTC")
|
||||
|> DateTime.to_unix(:millisecond)
|
||||
end
|
||||
|
||||
def show_name?(%{dynamic?: true, name: name, worker: worker}), do: name != worker
|
||||
def show_name?(_cron), do: false
|
||||
end
|
||||
332
vendor/oban_web/lib/oban/web/live/crons/new_component.ex
vendored
Normal file
332
vendor/oban_web/lib/oban/web/live/crons/new_component.ex
vendored
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
defmodule Oban.Web.Crons.NewComponent do
|
||||
use Oban.Web, :live_component
|
||||
|
||||
import Oban.Web.FormComponents
|
||||
|
||||
alias Oban.Pro.Plugins.DynamicCron
|
||||
alias Oban.Web.Timezones
|
||||
|
||||
@compile {:no_warn_undefined, DynamicCron}
|
||||
|
||||
@fields ~w(args expression max_attempts name priority queue tags timezone worker)a
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="new-cron"
|
||||
class="relative z-50 hidden"
|
||||
phx-mounted={show_drawer()}
|
||||
phx-remove={hide_drawer()}
|
||||
phx-window-keydown="keydown"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div
|
||||
id="new-cron-bg"
|
||||
class="bg-zinc-50/80 dark:bg-zinc-950/80 fixed inset-0 hidden transition-opacity"
|
||||
aria-hidden="true"
|
||||
phx-click="close"
|
||||
phx-target={@myself}
|
||||
/>
|
||||
|
||||
<div class="fixed inset-0 overflow-hidden">
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
|
||||
<div
|
||||
id="new-cron-panel"
|
||||
class="pointer-events-auto w-screen max-w-md hidden transition-transform translate-x-full"
|
||||
>
|
||||
<div class="flex h-full flex-col overflow-y-scroll bg-white dark:bg-gray-900 shadow-xl">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
New Dynamic Cron
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer"
|
||||
phx-click="close"
|
||||
phx-target={@myself}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icons.x_mark class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="new-cron-form"
|
||||
class="flex-1 px-4 py-6 space-y-4"
|
||||
phx-change="form-change"
|
||||
phx-submit="create-cron"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.form_field
|
||||
label="Worker"
|
||||
name="worker"
|
||||
value={@form.worker}
|
||||
placeholder="MyApp.Workers.SomeWorker"
|
||||
required={true}
|
||||
/>
|
||||
|
||||
<.form_field
|
||||
label="Name"
|
||||
name="name"
|
||||
value={@form.name}
|
||||
placeholder="some-worker"
|
||||
required={true}
|
||||
/>
|
||||
|
||||
<.form_field
|
||||
label="Expression"
|
||||
name="expression"
|
||||
value={@form.expression}
|
||||
placeholder="* * * * *"
|
||||
required={true}
|
||||
/>
|
||||
|
||||
<.select_field
|
||||
label="Queue"
|
||||
name="queue"
|
||||
value={@form.queue}
|
||||
options={queue_options(@queues)}
|
||||
/>
|
||||
|
||||
<.select_field
|
||||
label="Timezone"
|
||||
name="timezone"
|
||||
value={@form.timezone}
|
||||
options={Timezones.options_with_blank()}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<.form_field
|
||||
label="Priority"
|
||||
name="priority"
|
||||
value={@form.priority}
|
||||
type="number"
|
||||
placeholder="0"
|
||||
/>
|
||||
|
||||
<.form_field
|
||||
label="Max Attempts"
|
||||
name="max_attempts"
|
||||
value={@form.max_attempts}
|
||||
type="number"
|
||||
placeholder="20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<.form_field label="Tags" name="tags" value={@form.tags} placeholder="tag1, tag2" />
|
||||
|
||||
<div class="flex items-end pb-2">
|
||||
<.checkbox_field
|
||||
label="Guaranteed"
|
||||
name="guaranteed"
|
||||
checked={@form.guaranteed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.form_field
|
||||
label="Args"
|
||||
name="args"
|
||||
value={@form.args}
|
||||
type="textarea"
|
||||
placeholder="{}"
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
<div class="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 cursor-pointer"
|
||||
>
|
||||
Create Cron
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Callbacks
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:form, fn -> default_form() end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
# Events
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def handle_event("form-change", params, socket) do
|
||||
form = socket.assigns.form
|
||||
|
||||
# Auto-generate name from worker if name is empty or matches previous auto-generated name
|
||||
name =
|
||||
if params["name"] == "" or params["name"] == form.name do
|
||||
worker_to_name(params["worker"])
|
||||
else
|
||||
params["name"]
|
||||
end
|
||||
|
||||
form =
|
||||
@fields
|
||||
|> Map.new(fn key -> {key, params[to_string(key)]} end)
|
||||
|> Map.put(:name, name)
|
||||
|> Map.put(:guaranteed, params["guaranteed"] == "true")
|
||||
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
|
||||
def handle_event("create-cron", params, socket) do
|
||||
enforce_access!(:insert_crons, socket.assigns.access)
|
||||
|
||||
%{conf: conf} = socket.assigns
|
||||
|
||||
with {:ok, worker} <- parse_worker(params["worker"]),
|
||||
{:ok, opts} <- build_opts(params) do
|
||||
case DynamicCron.insert(conf.name, [{params["expression"], worker, opts}]) do
|
||||
{:ok, _entries} ->
|
||||
send(self(), {:flash, :info, "Cron '#{params["name"]}' created successfully"})
|
||||
{:noreply, push_patch(socket, to: oban_path(:crons))}
|
||||
|
||||
{:error, reason} ->
|
||||
send(self(), {:flash, :error, "Failed to create cron: #{inspect(reason)}"})
|
||||
{:noreply, socket}
|
||||
end
|
||||
else
|
||||
{:error, message} ->
|
||||
send(self(), {:flash, :error, message})
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("close", _params, socket) do
|
||||
{:noreply, push_patch(socket, to: oban_path(:crons))}
|
||||
end
|
||||
|
||||
def handle_event("keydown", %{"key" => "Escape"}, socket) do
|
||||
{:noreply, push_patch(socket, to: oban_path(:crons))}
|
||||
end
|
||||
|
||||
def handle_event("keydown", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# JS Commands
|
||||
|
||||
defp show_drawer do
|
||||
%JS{}
|
||||
|> JS.show(to: "#new-cron")
|
||||
|> JS.show(
|
||||
to: "#new-cron-bg",
|
||||
transition: {"ease-out duration-300", "opacity-0", "opacity-100"}
|
||||
)
|
||||
|> JS.show(
|
||||
to: "#new-cron-panel",
|
||||
transition: {"ease-out duration-300", "translate-x-full", "translate-x-0"}
|
||||
)
|
||||
|> JS.add_class("overflow-hidden", to: "body")
|
||||
end
|
||||
|
||||
defp hide_drawer do
|
||||
%JS{}
|
||||
|> JS.hide(
|
||||
to: "#new-cron-bg",
|
||||
transition: {"ease-in duration-200", "opacity-100", "opacity-0"}
|
||||
)
|
||||
|> JS.hide(
|
||||
to: "#new-cron-panel",
|
||||
time: 200,
|
||||
transition: {"ease-in duration-200", "translate-x-0", "translate-x-full"}
|
||||
)
|
||||
|> JS.hide(to: "#new-cron", transition: {"block", "block", "hidden"})
|
||||
|> JS.remove_class("overflow-hidden", to: "body")
|
||||
end
|
||||
|
||||
# Helpers
|
||||
|
||||
defp default_form do
|
||||
@fields
|
||||
|> Map.new(&{&1, ""})
|
||||
|> Map.put(:guaranteed, false)
|
||||
end
|
||||
|
||||
defp worker_to_name(""), do: ""
|
||||
|
||||
defp worker_to_name(worker) do
|
||||
worker
|
||||
|> String.split(".")
|
||||
|> List.last()
|
||||
|> Macro.underscore()
|
||||
|> String.replace("_", "-")
|
||||
end
|
||||
|
||||
defp parse_worker(worker) when is_binary(worker) and worker != "" do
|
||||
{:ok, String.trim(worker)}
|
||||
end
|
||||
|
||||
defp parse_worker(_), do: {:error, "Worker is required"}
|
||||
|
||||
defp build_opts(params) do
|
||||
opts = [name: params["name"]]
|
||||
|
||||
opts =
|
||||
if params["guaranteed"] == "true" do
|
||||
Keyword.put(opts, :guaranteed, true)
|
||||
else
|
||||
opts
|
||||
end
|
||||
|
||||
opts =
|
||||
if params["queue"] != "" do
|
||||
Keyword.put(opts, :queue, params["queue"])
|
||||
else
|
||||
opts
|
||||
end
|
||||
|
||||
opts =
|
||||
if params["timezone"] != "" do
|
||||
Keyword.put(opts, :timezone, params["timezone"])
|
||||
else
|
||||
opts
|
||||
end
|
||||
|
||||
opts =
|
||||
case parse_int(params["priority"]) do
|
||||
nil -> opts
|
||||
priority -> Keyword.put(opts, :priority, priority)
|
||||
end
|
||||
|
||||
opts =
|
||||
case parse_int(params["max_attempts"]) do
|
||||
nil -> opts
|
||||
max_attempts -> Keyword.put(opts, :max_attempts, max_attempts)
|
||||
end
|
||||
|
||||
opts =
|
||||
case parse_tags(params["tags"]) do
|
||||
nil -> opts
|
||||
tags -> Keyword.put(opts, :tags, tags)
|
||||
end
|
||||
|
||||
opts =
|
||||
case parse_json(params["args"]) do
|
||||
nil -> opts
|
||||
args -> Keyword.put(opts, :args, args)
|
||||
end
|
||||
|
||||
{:ok, opts}
|
||||
end
|
||||
end
|
||||
254
vendor/oban_web/lib/oban/web/live/crons/table_component.ex
vendored
Normal file
254
vendor/oban_web/lib/oban/web/live/crons/table_component.ex
vendored
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
defmodule Oban.Web.Crons.TableComponent do
|
||||
use Oban.Web, :live_component
|
||||
|
||||
import Oban.Web.Crons.Helpers, only: [maybe_to_unix: 1, show_name?: 1, state_icon: 1]
|
||||
|
||||
alias Oban.Web.{Colors, Cron}
|
||||
|
||||
@sparkline_count 60
|
||||
@sparkline_height 16
|
||||
@sparkline_bar_width 4
|
||||
@sparkline_gap 1
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="crons-table" class="min-w-full">
|
||||
<ul class="flex items-center border-b border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500">
|
||||
<.header label="name" class="pl-3 w-1/3 text-left" />
|
||||
<div class="ml-auto flex items-center space-x-6">
|
||||
<.header label="history" class="w-80 text-center" />
|
||||
<.header label="schedule" class="w-32 text-right" />
|
||||
<.header label="last run" class="w-32 text-right" />
|
||||
<.header label="next run" class="w-32 text-right" />
|
||||
<.header label="status" class="w-20 pr-4 text-right" />
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<div :if={Enum.empty?(@crontab)} class="py-16 px-6 text-center">
|
||||
<Icons.clock class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<h3 class="mt-4 text-xl font-semibold text-gray-900 dark:text-gray-100">No crons</h3>
|
||||
<p class="mt-2 text-base text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
Crons run jobs on a schedule. Configure them in your Oban supervisor or create them dynamically.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<a
|
||||
href="https://hexdocs.pm/oban/periodic_jobs.html"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-base font-medium text-violet-600 hover:text-violet-500 dark:text-violet-400 dark:hover:text-violet-300"
|
||||
>
|
||||
Learn about crons <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<.cron_row :for={cron <- @crontab} id={cron.name} cron={cron} />
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :class, :string, default: ""
|
||||
|
||||
defp header(assigns) do
|
||||
~H"""
|
||||
<span class={[@class, "text-xs font-medium uppercase tracking-wider py-1.5"]}>
|
||||
{@label}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :history, :list, required: true
|
||||
attr :id, :string, required: true
|
||||
|
||||
defp sparkline(assigns) do
|
||||
history = Enum.take(assigns.history, -@sparkline_count)
|
||||
offset = @sparkline_count - length(history)
|
||||
|
||||
bars =
|
||||
for {job, index} <- Enum.with_index(history) do
|
||||
x = (offset + index) * (@sparkline_bar_width + @sparkline_gap)
|
||||
%{x: x, color: state_color(job.state)}
|
||||
end
|
||||
|
||||
tooltip_data =
|
||||
for job <- history do
|
||||
unix =
|
||||
(job.finished_at || job.attempted_at || job.scheduled_at)
|
||||
|> DateTime.from_naive!("Etc/UTC")
|
||||
|> DateTime.to_unix(:millisecond)
|
||||
|
||||
%{timestamp: unix, state: job.state}
|
||||
end
|
||||
|
||||
placeholders =
|
||||
for slot <- 0..(@sparkline_count - 1) do
|
||||
%{x: slot * (@sparkline_bar_width + @sparkline_gap)}
|
||||
end
|
||||
|
||||
width = @sparkline_count * (@sparkline_bar_width + @sparkline_gap)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(bars: bars, placeholders: placeholders, width: width, offset: offset)
|
||||
|> assign(height: @sparkline_height, bar_width: @sparkline_bar_width)
|
||||
|> assign(tooltip_data: tooltip_data)
|
||||
|
||||
~H"""
|
||||
<svg
|
||||
id={@id}
|
||||
width={@width}
|
||||
height={@height}
|
||||
viewBox={"0 0 #{@width} #{@height}"}
|
||||
class="flex-shrink-0 cursor-pointer"
|
||||
phx-hook="CronSparkline"
|
||||
data-tooltip={Oban.JSON.encode!(@tooltip_data)}
|
||||
data-bar-width={@bar_width}
|
||||
data-offset={@offset}
|
||||
>
|
||||
<rect
|
||||
:for={placeholder <- @placeholders}
|
||||
x={placeholder.x}
|
||||
y={@height - 2}
|
||||
width={@bar_width}
|
||||
height="2"
|
||||
fill="#e5e7eb"
|
||||
class="dark:fill-gray-700"
|
||||
rx="0.5"
|
||||
/>
|
||||
<rect
|
||||
:for={bar <- @bars}
|
||||
x={bar.x}
|
||||
y="0"
|
||||
width={@bar_width}
|
||||
height={@height}
|
||||
fill={bar.color}
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
defp state_color(state), do: Colors.state_hex(state)
|
||||
|
||||
attr :cron, Cron
|
||||
attr :id, :string
|
||||
|
||||
defp cron_row(assigns) do
|
||||
~H"""
|
||||
<li id={"cron-#{@id}"} class="flex items-center hover:bg-gray-50 dark:hover:bg-gray-950/30">
|
||||
<.link patch={oban_path([:crons, @cron.name])} class="pl-3 py-3.5 flex flex-grow items-center">
|
||||
<div class="w-1/3">
|
||||
<span class="font-semibold text-sm text-gray-700 dark:text-gray-300">
|
||||
{@cron.worker}
|
||||
<span :if={show_name?(@cron)} class="font-normal text-gray-500 dark:text-gray-400">
|
||||
({@cron.name})
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div
|
||||
:if={@cron.dynamic? or has_tags?(@cron.opts) or format_opts(@cron.opts)}
|
||||
class="flex flex-wrap items-center gap-1.5 mt-1"
|
||||
>
|
||||
<span
|
||||
:if={@cron.dynamic?}
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-300"
|
||||
>
|
||||
dynamic
|
||||
</span>
|
||||
|
||||
<span
|
||||
:for={tag <- get_tags(@cron.opts)}
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
|
||||
<samp
|
||||
:if={format_opts(@cron.opts)}
|
||||
class="font-mono text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{format_opts(@cron.opts)}
|
||||
</samp>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center space-x-6 tabular text-gray-500 dark:text-gray-300">
|
||||
<div class="w-80 flex justify-center">
|
||||
<.sparkline id={"sparkline-#{@cron.name}"} history={@cron.history} />
|
||||
</div>
|
||||
|
||||
<span class="w-32 text-right font-mono text-sm">
|
||||
{@cron.expression}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="w-32 text-right text-sm"
|
||||
id={"cron-lts-#{@cron.name}"}
|
||||
data-timestamp={maybe_to_unix(@cron.last_at)}
|
||||
phx-hook="Relativize"
|
||||
phx-update="ignore"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="w-32 text-right text-sm"
|
||||
id={"cron-nts-#{@cron.name}"}
|
||||
data-timestamp={maybe_to_unix(@cron.next_at)}
|
||||
phx-hook="Relativize"
|
||||
phx-update="ignore"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
|
||||
<div class="w-20 pr-4 flex justify-end">
|
||||
<span
|
||||
id={"cron-state-icon-#{@cron.name}"}
|
||||
phx-hook="Tippy"
|
||||
data-title={state_title(@cron)}
|
||||
>
|
||||
<.state_icon state={@cron.last_state} paused={@cron.paused?} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</.link>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
defp state_title(%{paused?: true}), do: "Paused"
|
||||
|
||||
defp state_title(cron) do
|
||||
case cron.last_state do
|
||||
nil ->
|
||||
"Unknown, no previous runs"
|
||||
|
||||
state ->
|
||||
"#{String.capitalize(state)} as of #{NaiveDateTime.truncate(cron.last_at, :second)}"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_opts(opts) when map_size(opts) == 0, do: nil
|
||||
|
||||
defp format_opts(opts) do
|
||||
opts
|
||||
|> Map.drop(["tags"])
|
||||
|> case do
|
||||
filtered when map_size(filtered) == 0 ->
|
||||
nil
|
||||
|
||||
filtered ->
|
||||
filtered
|
||||
|> Enum.map_join(", ", fn {key, val} -> "#{key}: #{inspect(val)}" end)
|
||||
|> truncate(0..98)
|
||||
end
|
||||
end
|
||||
|
||||
defp has_tags?(opts), do: Map.has_key?(opts, "tags") and opts["tags"] != []
|
||||
|
||||
defp get_tags(opts), do: Map.get(opts, "tags", [])
|
||||
end
|
||||
55
vendor/oban_web/lib/oban/web/live/help_component.ex
vendored
Normal file
55
vendor/oban_web/lib/oban/web/live/help_component.ex
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
defmodule Oban.Web.HelpComponent do
|
||||
use Oban.Web, :live_component
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="relative" id="help-selector">
|
||||
<button
|
||||
aria-expanded="true"
|
||||
aria-haspopup="listbox"
|
||||
class="cursor-pointer text-gray-500 dark:text-gray-400 focus:outline-none hover:text-gray-700 dark:hover:text-gray-200 hidden md:block"
|
||||
data-title="Help"
|
||||
id="help-menu-toggle"
|
||||
phx-hook="Tippy"
|
||||
phx-click={JS.toggle(to: "#help-menu")}
|
||||
type="button"
|
||||
>
|
||||
<Icons.question_mark_circle />
|
||||
</button>
|
||||
|
||||
<ul
|
||||
class="hidden absolute z-50 top-full right-0 mt-2 py-2 w-48 overflow-hidden rounded-md shadow-lg text-sm font-semibold bg-white dark:bg-gray-800 focus:outline-none"
|
||||
id="help-menu"
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
>
|
||||
<li
|
||||
class="block w-full py-1 px-2 flex items-center cursor-pointer space-x-2 text-gray-500 dark:text-gray-400 hover:bg-gray-50 hover:dark:bg-gray-600/30"
|
||||
phx-click-away={JS.hide(to: "#help-menu")}
|
||||
role="option"
|
||||
>
|
||||
<a
|
||||
href="https://hexdocs.pm/oban_web"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center space-x-2 w-full"
|
||||
>
|
||||
<Icons.arrow_top_right_on_square class="w-5 h-5" />
|
||||
<span class="text-gray-800 dark:text-gray-200">Documentation</span>
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
class="block w-full py-1 px-2 flex items-center cursor-pointer space-x-2 text-gray-500 dark:text-gray-400 hover:bg-gray-50 hover:dark:bg-gray-600/30"
|
||||
phx-click-away={JS.hide(to: "#help-menu")}
|
||||
phx-click={JS.hide(to: "#help-menu") |> JS.exec("data-shortcut", to: "#shortcuts")}
|
||||
role="option"
|
||||
>
|
||||
<Icons.command_line class="w-5 h-5" />
|
||||
<span class="text-gray-800 dark:text-gray-200">Keyboard shortcuts</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
94
vendor/oban_web/lib/oban/web/live/instances_component.ex
vendored
Normal file
94
vendor/oban_web/lib/oban/web/live/instances_component.ex
vendored
Normal 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
|
||||
308
vendor/oban_web/lib/oban/web/live/jobs/chart_component.ex
vendored
Normal file
308
vendor/oban_web/lib/oban/web/live/jobs/chart_component.ex
vendored
Normal 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
|
||||
996
vendor/oban_web/lib/oban/web/live/jobs/detail_component.ex
vendored
Normal file
996
vendor/oban_web/lib/oban/web/live/jobs/detail_component.ex
vendored
Normal 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
|
||||
106
vendor/oban_web/lib/oban/web/live/jobs/history_chart_component.ex
vendored
Normal file
106
vendor/oban_web/lib/oban/web/live/jobs/history_chart_component.ex
vendored
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
defmodule Oban.Web.Jobs.HistoryChartComponent do
|
||||
use Oban.Web, :live_component
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<button
|
||||
id="history-toggle"
|
||||
type="button"
|
||||
class="flex items-center w-full space-x-2 px-2 py-1.5 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
phx-click={toggle_section()}
|
||||
>
|
||||
<Icons.chevron_right id="history-chevron" class="w-5 h-5 transition-transform rotate-90" />
|
||||
<span class="font-semibold">Recent Executions</span>
|
||||
</button>
|
||||
|
||||
<div id="history-content" class="group relative mt-3">
|
||||
<div
|
||||
id="job-history-chart"
|
||||
class="h-48 bg-gray-50 dark:bg-gray-800 rounded-md p-4"
|
||||
phx-hook="JobHistoryChart"
|
||||
phx-update="ignore"
|
||||
data-current-job-id={@job.id}
|
||||
>
|
||||
</div>
|
||||
<.link
|
||||
navigate={all_jobs_path(@job)}
|
||||
class="absolute right-4 top-4 flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900 dark:hover:text-blue-300 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
View all jobs <Icons.arrow_right class="w-3 h-3" />
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp toggle_section do
|
||||
%JS{}
|
||||
|> JS.toggle(to: "#history-content", in: "fade-in-scale", out: "fade-out-scale")
|
||||
|> JS.add_class("rotate-90", to: "#history-chevron:not(.rotate-90)")
|
||||
|> JS.remove_class("rotate-90", to: "#history-chevron.rotate-90")
|
||||
end
|
||||
|
||||
defp all_jobs_path(job) do
|
||||
worker = Map.get(job.meta, "worker", job.worker)
|
||||
oban_path(:jobs, %{workers: [worker], state: "completed"})
|
||||
end
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> push_chart_data()
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp push_chart_data(socket) do
|
||||
%{job: job, history: history} = socket.assigns
|
||||
|
||||
chart_data =
|
||||
Enum.map(history, fn hist_job ->
|
||||
%{
|
||||
id: hist_job.id,
|
||||
timestamp: timestamp_for(hist_job),
|
||||
wait_time: wait_time_for(hist_job),
|
||||
exec_time: exec_time_for(hist_job),
|
||||
state: hist_job.state,
|
||||
current: hist_job.id == job.id
|
||||
}
|
||||
end)
|
||||
|
||||
push_event(socket, "job-history", %{history: chart_data})
|
||||
end
|
||||
|
||||
defp timestamp_for(job) do
|
||||
datetime =
|
||||
job.completed_at || job.cancelled_at || job.discarded_at || job.attempted_at ||
|
||||
job.scheduled_at
|
||||
|
||||
datetime
|
||||
|> DateTime.from_naive!("Etc/UTC")
|
||||
|> DateTime.to_unix(:millisecond)
|
||||
end
|
||||
|
||||
defp exec_time_for(job) do
|
||||
case {job.attempted_at, finished_at(job)} do
|
||||
{nil, _} -> 0
|
||||
{_, nil} -> 0
|
||||
{attempted_at, finished_at} -> NaiveDateTime.diff(finished_at, attempted_at, :millisecond)
|
||||
end
|
||||
end
|
||||
|
||||
defp wait_time_for(job) do
|
||||
case job.attempted_at do
|
||||
nil -> 0
|
||||
attempted_at -> NaiveDateTime.diff(attempted_at, job.scheduled_at, :millisecond)
|
||||
end
|
||||
end
|
||||
|
||||
defp finished_at(job) do
|
||||
job.completed_at || job.cancelled_at || job.discarded_at
|
||||
end
|
||||
end
|
||||
290
vendor/oban_web/lib/oban/web/live/jobs/new_component.ex
vendored
Normal file
290
vendor/oban_web/lib/oban/web/live/jobs/new_component.ex
vendored
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
defmodule Oban.Web.Jobs.NewComponent do
|
||||
use Oban.Web, :live_component
|
||||
|
||||
import Oban.Web.FormComponents
|
||||
|
||||
@fields ~w(args max_attempts priority queue scheduled_at tags worker)a
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="new-job"
|
||||
class="relative z-50 hidden"
|
||||
phx-mounted={show_drawer()}
|
||||
phx-remove={hide_drawer()}
|
||||
phx-window-keydown="keydown"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<div
|
||||
id="new-job-bg"
|
||||
class="bg-zinc-50/80 dark:bg-zinc-950/80 fixed inset-0 hidden transition-opacity"
|
||||
aria-hidden="true"
|
||||
phx-click="close"
|
||||
phx-target={@myself}
|
||||
/>
|
||||
|
||||
<div class="fixed inset-0 overflow-hidden">
|
||||
<div class="absolute inset-0 overflow-hidden">
|
||||
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
|
||||
<div
|
||||
id="new-job-panel"
|
||||
class="pointer-events-auto w-screen max-w-md hidden transition-transform translate-x-full"
|
||||
>
|
||||
<div class="flex h-full flex-col overflow-y-scroll bg-white dark:bg-gray-900 shadow-xl">
|
||||
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
New Job
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 cursor-pointer"
|
||||
phx-click="close"
|
||||
phx-target={@myself}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icons.x_mark class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="new-job-form"
|
||||
class="flex-1 px-4 py-6 space-y-4"
|
||||
phx-change="form-change"
|
||||
phx-submit="create-job"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.form_field
|
||||
label="Worker"
|
||||
name="worker"
|
||||
value={@form.worker}
|
||||
placeholder="MyApp.Workers.SomeWorker"
|
||||
required={true}
|
||||
/>
|
||||
|
||||
<.form_field
|
||||
label="Args"
|
||||
name="args"
|
||||
value={@form.args}
|
||||
type="textarea"
|
||||
placeholder="{}"
|
||||
required={true}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<.select_field
|
||||
label="Queue"
|
||||
name="queue"
|
||||
value={@form.queue}
|
||||
options={queue_options(@queues)}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<.form_field
|
||||
label="Priority"
|
||||
name="priority"
|
||||
value={@form.priority}
|
||||
type="number"
|
||||
placeholder="0"
|
||||
hint="0 (highest) to 9 (lowest)"
|
||||
/>
|
||||
|
||||
<.form_field
|
||||
label="Max Attempts"
|
||||
name="max_attempts"
|
||||
value={@form.max_attempts}
|
||||
type="number"
|
||||
placeholder="20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.form_field
|
||||
label="Scheduled At"
|
||||
name="scheduled_at"
|
||||
value={@form.scheduled_at}
|
||||
type="datetime-local"
|
||||
hint="Leave empty to run immediately"
|
||||
/>
|
||||
|
||||
<.form_field label="Tags" name="tags" value={@form.tags} placeholder="tag1, tag2" />
|
||||
|
||||
<div class="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full px-4 py-2 bg-blue-500 text-white text-sm font-medium rounded-md hover:bg-blue-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 cursor-pointer"
|
||||
>
|
||||
Create Job
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:form, fn -> default_form() end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def handle_event("form-change", params, socket) do
|
||||
form = Map.new(@fields, fn key -> {key, params[to_string(key)]} end)
|
||||
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
|
||||
def handle_event("create-job", params, socket) do
|
||||
enforce_access!(:insert_jobs, socket.assigns.access)
|
||||
|
||||
%{conf: conf} = socket.assigns
|
||||
|
||||
with {:ok, worker} <- parse_worker(params["worker"]),
|
||||
{:ok, args} <- parse_args(params["args"]),
|
||||
{:ok, opts} <- build_opts(params) do
|
||||
changeset = Oban.Job.new(args, [{:worker, worker} | opts])
|
||||
|
||||
case Oban.insert(conf.name, changeset) do
|
||||
{:ok, job} ->
|
||||
send(self(), {:flash, :info, "Job created successfully"})
|
||||
{:noreply, push_patch(socket, to: oban_path([:jobs, job.id]))}
|
||||
|
||||
{:error, changeset} ->
|
||||
message = format_changeset_error(changeset)
|
||||
send(self(), {:flash, :error, "Failed to create job: #{message}"})
|
||||
{:noreply, socket}
|
||||
end
|
||||
else
|
||||
{:error, message} ->
|
||||
send(self(), {:flash, :error, message})
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("close", _params, socket) do
|
||||
{:noreply, push_patch(socket, to: oban_path(:jobs))}
|
||||
end
|
||||
|
||||
def handle_event("keydown", %{"key" => "Escape"}, socket) do
|
||||
{:noreply, push_patch(socket, to: oban_path(:jobs))}
|
||||
end
|
||||
|
||||
def handle_event("keydown", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# JS Commands
|
||||
|
||||
defp show_drawer do
|
||||
%JS{}
|
||||
|> JS.show(to: "#new-job")
|
||||
|> JS.show(
|
||||
to: "#new-job-bg",
|
||||
transition: {"ease-out duration-300", "opacity-0", "opacity-100"}
|
||||
)
|
||||
|> JS.show(
|
||||
to: "#new-job-panel",
|
||||
transition: {"ease-out duration-300", "translate-x-full", "translate-x-0"}
|
||||
)
|
||||
|> JS.add_class("overflow-hidden", to: "body")
|
||||
end
|
||||
|
||||
defp hide_drawer do
|
||||
%JS{}
|
||||
|> JS.hide(
|
||||
to: "#new-job-bg",
|
||||
transition: {"ease-in duration-200", "opacity-100", "opacity-0"}
|
||||
)
|
||||
|> JS.hide(
|
||||
to: "#new-job-panel",
|
||||
time: 200,
|
||||
transition: {"ease-in duration-200", "translate-x-0", "translate-x-full"}
|
||||
)
|
||||
|> JS.hide(to: "#new-job", transition: {"block", "block", "hidden"})
|
||||
|> JS.remove_class("overflow-hidden", to: "body")
|
||||
end
|
||||
|
||||
# Helpers
|
||||
|
||||
defp default_form do
|
||||
@fields
|
||||
|> Map.new(&{&1, ""})
|
||||
|> Map.put(:args, "{}")
|
||||
end
|
||||
|
||||
defp parse_worker(worker) when is_binary(worker) and worker != "" do
|
||||
{:ok, String.trim(worker)}
|
||||
end
|
||||
|
||||
defp parse_worker(_), do: {:error, "Worker is required"}
|
||||
|
||||
defp parse_args(args) when is_binary(args) do
|
||||
case parse_json(args) do
|
||||
nil -> {:error, "Args must be a valid JSON object"}
|
||||
map -> {:ok, map}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_args(_), do: {:error, "Args must be a valid JSON object"}
|
||||
|
||||
defp build_opts(params) do
|
||||
queue = if params["queue"] in [nil, ""], do: "default", else: params["queue"]
|
||||
opts = [queue: queue]
|
||||
|
||||
opts =
|
||||
case parse_int(params["priority"]) do
|
||||
nil -> opts
|
||||
priority when priority >= 0 and priority <= 9 -> Keyword.put(opts, :priority, priority)
|
||||
_ -> opts
|
||||
end
|
||||
|
||||
opts =
|
||||
case parse_int(params["max_attempts"]) do
|
||||
nil -> opts
|
||||
max_attempts -> Keyword.put(opts, :max_attempts, max_attempts)
|
||||
end
|
||||
|
||||
opts =
|
||||
case parse_scheduled_at(params["scheduled_at"]) do
|
||||
nil -> opts
|
||||
scheduled_at -> Keyword.put(opts, :scheduled_at, scheduled_at)
|
||||
end
|
||||
|
||||
opts =
|
||||
case parse_tags(params["tags"]) do
|
||||
nil -> opts
|
||||
tags -> Keyword.put(opts, :tags, tags)
|
||||
end
|
||||
|
||||
{:ok, opts}
|
||||
end
|
||||
|
||||
defp parse_scheduled_at(nil), do: nil
|
||||
defp parse_scheduled_at(""), do: nil
|
||||
|
||||
defp parse_scheduled_at(str) when is_binary(str) do
|
||||
case DateTime.from_iso8601(str <> ":00Z") do
|
||||
{:ok, datetime} -> datetime
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp format_changeset_error(changeset) do
|
||||
changeset
|
||||
|> Ecto.Changeset.traverse_errors(fn {msg, opts} ->
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", to_string(value))
|
||||
end)
|
||||
end)
|
||||
|> Enum.map_join(", ", fn {key, errors} -> "#{key} #{Enum.join(errors, ", ")}" end)
|
||||
end
|
||||
end
|
||||
50
vendor/oban_web/lib/oban/web/live/jobs/sidebar_component.ex
vendored
Normal file
50
vendor/oban_web/lib/oban/web/live/jobs/sidebar_component.ex
vendored
Normal 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
|
||||
246
vendor/oban_web/lib/oban/web/live/jobs/table_component.ex
vendored
Normal file
246
vendor/oban_web/lib/oban/web/live/jobs/table_component.ex
vendored
Normal 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
|
||||
255
vendor/oban_web/lib/oban/web/live/jobs/timeline_component.ex
vendored
Normal file
255
vendor/oban_web/lib/oban/web/live/jobs/timeline_component.ex
vendored
Normal 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
|
||||
1100
vendor/oban_web/lib/oban/web/live/queues/detail_component.ex
vendored
Normal file
1100
vendor/oban_web/lib/oban/web/live/queues/detail_component.ex
vendored
Normal file
File diff suppressed because it is too large
Load diff
206
vendor/oban_web/lib/oban/web/live/queues/detail_instance_component.ex
vendored
Normal file
206
vendor/oban_web/lib/oban/web/live/queues/detail_instance_component.ex
vendored
Normal 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
|
||||
254
vendor/oban_web/lib/oban/web/live/queues/table_component.ex
vendored
Normal file
254
vendor/oban_web/lib/oban/web/live/queues/table_component.ex
vendored
Normal 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">→</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
|
||||
119
vendor/oban_web/lib/oban/web/live/refresh_component.ex
vendored
Normal file
119
vendor/oban_web/lib/oban/web/live/refresh_component.ex
vendored
Normal 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
|
||||
330
vendor/oban_web/lib/oban/web/live/search_component.ex
vendored
Normal file
330
vendor/oban_web/lib/oban/web/live/search_component.ex
vendored
Normal 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
|
||||
114
vendor/oban_web/lib/oban/web/live/shortcuts_component.ex
vendored
Normal file
114
vendor/oban_web/lib/oban/web/live/shortcuts_component.ex
vendored
Normal 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
|
||||
102
vendor/oban_web/lib/oban/web/live/theme_component.ex
vendored
Normal file
102
vendor/oban_web/lib/oban/web/live/theme_component.ex
vendored
Normal 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
|
||||
606
vendor/oban_web/lib/oban/web/live/workflows/detail_component.ex
vendored
Normal file
606
vendor/oban_web/lib/oban/web/live/workflows/detail_component.ex
vendored
Normal file
|
|
@ -0,0 +1,606 @@
|
|||
defmodule Oban.Web.Workflows.DetailComponent do
|
||||
use Oban.Web, :live_component
|
||||
|
||||
alias Oban.Web.Components.Core
|
||||
alias Oban.Web.Timing
|
||||
alias Oban.Web.WorkflowQuery
|
||||
|
||||
@states ~w(suspended available scheduled executing retryable completed cancelled discarded)a
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def update(assigns, socket) do
|
||||
sub_workflows = assigns[:sub_workflows] || []
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:sub_workflows, sub_workflows)
|
||||
|> assign_new(:graph_open?, fn -> true end)
|
||||
|> assign_new(:subs_open?, fn -> match?([_ | _], sub_workflows) end)
|
||||
|> assign_new(:graph_data, fn -> %{jobs: [], sub_workflows: []} end)
|
||||
|> push_graph_data()
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp push_graph_data(socket) do
|
||||
if socket.assigns[:graph_open?] do
|
||||
push_event(socket, "graph-data", socket.assigns.graph_data)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="workflow-details">
|
||||
<%= if @workflow do %>
|
||||
<.header
|
||||
access={@access}
|
||||
myself={@myself}
|
||||
pro_available?={@pro_available?}
|
||||
workflow={@workflow}
|
||||
parent_workflow={@parent_workflow}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-6 gap-6 px-3 py-6">
|
||||
<div class="col-span-4">
|
||||
<.progress_bar workflow={@workflow} subs={@sub_workflows} />
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<.stats_grid workflow={@workflow} sub_workflows={@sub_workflows} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.graph_section myself={@myself} graph_open?={@graph_open?} graph_data={@graph_data} />
|
||||
|
||||
<.sub_workflows_section
|
||||
myself={@myself}
|
||||
subs_open?={@subs_open?}
|
||||
sub_workflows={@sub_workflows}
|
||||
/>
|
||||
<% else %>
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="text-center">
|
||||
<Icons.rectangle_group class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<h3 class="mt-4 text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Workflow not found
|
||||
</h3>
|
||||
<p class="mt-2 text-base text-gray-500 dark:text-gray-400">
|
||||
This workflow may have been deleted or doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Header
|
||||
|
||||
attr :access, :any, required: true
|
||||
attr :myself, :any, required: true
|
||||
attr :pro_available?, :boolean, required: true
|
||||
attr :workflow, :map, required: true
|
||||
attr :parent_workflow, :map, default: nil
|
||||
|
||||
defp header(assigns) do
|
||||
~H"""
|
||||
<div class="flex justify-between items-center px-3 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
id="back-link"
|
||||
class="flex items-center hover:text-blue-500 cursor-pointer bg-transparent border-0 p-0"
|
||||
data-escape-back={true}
|
||||
phx-hook="HistoryBack"
|
||||
type="button"
|
||||
>
|
||||
<Icons.arrow_left class="w-5 h-5" />
|
||||
<span class="text-lg font-bold ml-2">{@workflow.name || @workflow.id}</span>
|
||||
</button>
|
||||
|
||||
<.parent_breadcrumb :if={@parent_workflow} parent={@parent_workflow} />
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<Core.icon_button
|
||||
id="detail-cancel"
|
||||
icon="x_circle"
|
||||
label="Cancel"
|
||||
color="yellow"
|
||||
tooltip={cancel_tooltip(@pro_available?)}
|
||||
disabled={
|
||||
not @pro_available? or not can?(:cancel_workflows, @access) or
|
||||
@workflow.state in ~w(completed cancelled)
|
||||
}
|
||||
phx-target={@myself}
|
||||
phx-click="cancel-workflow"
|
||||
/>
|
||||
|
||||
<Core.icon_button
|
||||
id="detail-retry"
|
||||
icon="arrow_path"
|
||||
label="Retry"
|
||||
color="blue"
|
||||
tooltip={retry_tooltip(@pro_available?)}
|
||||
disabled={
|
||||
not @pro_available? or not can?(:retry_workflows, @access) or
|
||||
not has_retryable?(@workflow)
|
||||
}
|
||||
phx-target={@myself}
|
||||
phx-click="retry-workflow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :parent, :map, required: true
|
||||
|
||||
defp parent_breadcrumb(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center ml-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Icons.arrow_turn_down_right class="w-4 h-4 mr-1" />
|
||||
<span>sub-workflow of</span>
|
||||
<.link
|
||||
navigate={oban_path([:workflows, @parent.id])}
|
||||
class="ml-1 font-medium text-violet-600 hover:text-violet-500 dark:text-violet-400"
|
||||
>
|
||||
{@parent.name || @parent.id}
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Progress Bar
|
||||
|
||||
attr :workflow, :map, required: true
|
||||
attr :subs, :list, default: []
|
||||
|
||||
defp progress_bar(assigns) do
|
||||
wf = assigns.workflow
|
||||
sub = count_sub_states(assigns.subs)
|
||||
|
||||
total = Enum.reduce(@states, 0, &(Map.fetch!(wf, &1) + &2)) + length(assigns.subs)
|
||||
completed = wf.completed + sub.completed
|
||||
|
||||
states = [
|
||||
{:suspended, wf.suspended + sub.suspended, "bg-gray-400", "Suspended"},
|
||||
{:scheduled, wf.scheduled + sub.scheduled, "bg-indigo-400", "Scheduled"},
|
||||
{:available, wf.available + sub.available, "bg-blue-400", "Available"},
|
||||
{:retryable, wf.retryable + sub.retryable, "bg-yellow-400", "Retryable"},
|
||||
{:executing, wf.executing + sub.executing, "bg-emerald-400", "Executing"},
|
||||
{:completed, completed, "bg-cyan-400", "Completed"},
|
||||
{:cancelled, wf.cancelled + sub.cancelled, "bg-violet-400", "Cancelled"},
|
||||
{:discarded, wf.discarded + sub.discarded, "bg-rose-400", "Discarded"}
|
||||
]
|
||||
|
||||
percent = if total > 0, do: round(completed / total * 100), else: 0
|
||||
|
||||
assigns =
|
||||
assign(assigns, states: states, total: total, completed: completed, percent: percent)
|
||||
|
||||
~H"""
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-md p-4" id="workflow-progress">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-base font-semibold text-gray-700 dark:text-gray-200">
|
||||
{@percent}% Complete
|
||||
</span>
|
||||
<span class="text-base text-gray-500 dark:text-gray-400 tabular">
|
||||
{@completed}/{@total} jobs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex">
|
||||
<%= for {_state, count, color, _label} <- @states, count > 0 do %>
|
||||
<div class={["h-full", color]} style={"width: #{count / max(@total, 1) * 100}%"} />
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between mt-3">
|
||||
<%= for {_state, count, color, label} <- @states do %>
|
||||
<div class="group flex items-center text-sm px-2 py-1 -mx-2 -my-1 rounded-full hover:bg-white dark:hover:bg-white/20 transition-all duration-200 cursor-default">
|
||||
<span class={["w-3 h-3 rounded-full shrink-0 mr-1.5", color]} />
|
||||
<span class="text-gray-600 dark:text-gray-400 whitespace-nowrap overflow-hidden transition-all duration-200 w-10 group-hover:w-20 [mask-image:linear-gradient(to_right,black_60%,transparent_100%)] group-hover:[mask-image:none]">
|
||||
{label}
|
||||
</span>
|
||||
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300 tabular">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Graph Section
|
||||
|
||||
attr :myself, :any, required: true
|
||||
attr :graph_open?, :boolean, required: true
|
||||
attr :graph_data, :map, required: true
|
||||
|
||||
defp graph_section(assigns) do
|
||||
~H"""
|
||||
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="px-3 py-6">
|
||||
<button
|
||||
id="graph-toggle"
|
||||
type="button"
|
||||
class="flex items-center w-full space-x-2 px-2 py-1.5 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
phx-click="toggle-graph"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<Icons.chevron_right class={[
|
||||
"w-5 h-5 transition-transform",
|
||||
@graph_open? && "rotate-90"
|
||||
]} />
|
||||
<span class="font-semibold">Workflow Graph</span>
|
||||
</button>
|
||||
|
||||
<div :if={@graph_open?} class="mt-3">
|
||||
<div
|
||||
id="workflow-graph"
|
||||
class="h-96 rounded-md overflow-hidden shadow-inner bg-gray-50 dark:bg-gray-900 workflow-graph-canvas"
|
||||
phx-hook="WorkflowGraph"
|
||||
phx-target={@myself}
|
||||
phx-update="ignore"
|
||||
>
|
||||
<svg class="w-full h-full"></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Stats Grid
|
||||
|
||||
attr :workflow, :any, required: true
|
||||
attr :sub_workflows, :list, required: true
|
||||
|
||||
defp stats_grid(assigns) do
|
||||
queues = Map.get(assigns.workflow.meta || %{}, "queues", [])
|
||||
assigns = assign(assigns, queues: queues)
|
||||
|
||||
~H"""
|
||||
<div class="flex flex-col gap-4 h-full" id="workflow-stats">
|
||||
<div class="grid grid-cols-4 gap-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
|
||||
<div class="flex flex-col col-span-4">
|
||||
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Workflow ID
|
||||
</span>
|
||||
<span class="text-base text-gray-800 dark:text-gray-200 font-mono">
|
||||
{@workflow.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Started
|
||||
</span>
|
||||
<span class="text-base text-gray-800 dark:text-gray-200">
|
||||
<.format_started_at workflow={@workflow} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Duration
|
||||
</span>
|
||||
<span class="text-base text-gray-800 dark:text-gray-200">
|
||||
<.format_duration workflow={@workflow} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col col-span-2">
|
||||
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Status
|
||||
</span>
|
||||
<span class="text-base text-gray-800 dark:text-gray-200 capitalize">
|
||||
{@workflow.state}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-4 px-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Subs
|
||||
</span>
|
||||
<span class="text-base text-gray-800 dark:text-gray-200 tabular">
|
||||
{length(@sub_workflows)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col col-span-3">
|
||||
<span class="uppercase font-semibold text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Queues
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
:for={queue <- @queues}
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-sm bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{queue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :workflow, :any, required: true
|
||||
|
||||
defp format_started_at(assigns) do
|
||||
wf = assigns.workflow
|
||||
executed? = wf.executing + wf.completed > 0
|
||||
started = if executed?, do: wf.started_at
|
||||
assigns = assign(assigns, started: started)
|
||||
|
||||
~H"""
|
||||
<span
|
||||
:if={@started}
|
||||
id={"wf-detail-started-#{@workflow.id}"}
|
||||
data-timestamp={DateTime.to_unix(@started, :millisecond)}
|
||||
phx-hook="Relativize"
|
||||
phx-update="ignore"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
<span :if={is_nil(@started)}>—</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :workflow, :any, required: true
|
||||
|
||||
defp format_duration(assigns) do
|
||||
wf = assigns.workflow
|
||||
executing? = wf.state == "executing"
|
||||
started? = not is_nil(wf.started_at)
|
||||
|
||||
duration =
|
||||
if wf.started_at && wf.completed_at do
|
||||
DateTime.diff(wf.completed_at, wf.started_at, :millisecond)
|
||||
end
|
||||
|
||||
formatted =
|
||||
if is_nil(duration) or duration <= 0 do
|
||||
"—"
|
||||
else
|
||||
duration |> div(1000) |> Timing.to_duration()
|
||||
end
|
||||
|
||||
assigns =
|
||||
assign(assigns,
|
||||
executing?: executing?,
|
||||
started?: started?,
|
||||
formatted: formatted,
|
||||
started_at: wf.started_at
|
||||
)
|
||||
|
||||
~H"""
|
||||
<span
|
||||
:if={@executing? and @started?}
|
||||
id={"wf-detail-duration-#{@workflow.id}"}
|
||||
data-timestamp={DateTime.to_unix(@started_at, :millisecond)}
|
||||
data-relative-mode="duration"
|
||||
phx-hook="Relativize"
|
||||
phx-update="ignore"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
<span :if={not (@executing? and @started?)}>
|
||||
{@formatted}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
# Sub-workflows Section
|
||||
|
||||
attr :myself, :any, required: true
|
||||
attr :subs_open?, :boolean, required: true
|
||||
attr :sub_workflows, :list, required: true
|
||||
|
||||
defp sub_workflows_section(assigns) do
|
||||
subs_count = length(assigns.sub_workflows)
|
||||
assigns = assign(assigns, subs_count: subs_count)
|
||||
|
||||
~H"""
|
||||
<div class="border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="px-3 py-6">
|
||||
<button
|
||||
id="subs-toggle"
|
||||
type="button"
|
||||
class="flex items-center w-full space-x-2 px-2 py-1.5 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
phx-click="toggle-subs"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<Icons.chevron_right class={[
|
||||
"w-5 h-5 transition-transform",
|
||||
@subs_open? && "rotate-90"
|
||||
]} />
|
||||
<span class="font-semibold">
|
||||
Sub-workflows
|
||||
<span class="text-gray-400 font-normal">
|
||||
({@subs_count})
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div :if={@subs_open? and @subs_count > 0} class="mt-3">
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-md overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-950 text-gray-500 dark:text-gray-500">
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider">
|
||||
Progress
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider">
|
||||
Started
|
||||
</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th class="px-3 py-2 text-center text-xs font-medium uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-800 bg-white dark:bg-gray-900">
|
||||
<.sub_workflow_row :for={sub <- @sub_workflows} workflow={sub} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :workflow, :any, required: true
|
||||
|
||||
defp sub_workflow_row(assigns) do
|
||||
wf = assigns.workflow
|
||||
total = Enum.reduce(@states, 0, &(Map.fetch!(wf, &1) + &2))
|
||||
percent = if total > 0, do: round(wf.completed / total * 100), else: 0
|
||||
|
||||
assigns =
|
||||
assign(assigns,
|
||||
completed: wf.completed,
|
||||
total: total,
|
||||
percent: percent,
|
||||
state: wf.state
|
||||
)
|
||||
|
||||
~H"""
|
||||
<.link navigate={oban_path([:workflows, @workflow.id])} class="contents">
|
||||
<tr class="hover:bg-gray-100 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<td class="px-3 py-3 font-medium text-sm text-gray-700 dark:text-gray-300">
|
||||
{@workflow.name || @workflow.id}
|
||||
</td>
|
||||
<td class="px-3 py-3 text-sm text-gray-500 dark:text-gray-400 font-mono">
|
||||
{@workflow.id}
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex items-center">
|
||||
<div class="w-24 h-1.5 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden mr-2">
|
||||
<div class="h-full rounded-full bg-cyan-400" style={"width: #{@percent}%"} />
|
||||
</div>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 tabular">
|
||||
{@completed}/{@total}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<.format_started_at workflow={@workflow} />
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right text-sm text-gray-500 dark:text-gray-400">
|
||||
<.format_duration workflow={@workflow} />
|
||||
</td>
|
||||
<td class="px-3 py-3 text-center">
|
||||
<.status_icon state={@state} />
|
||||
</td>
|
||||
</tr>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :state, :string, default: nil
|
||||
|
||||
defp status_icon(assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex">
|
||||
<%= case @state do %>
|
||||
<% "executing" -> %>
|
||||
<Icons.play_circle class="w-5 h-5 text-emerald-400" />
|
||||
<% "completed" -> %>
|
||||
<Icons.check_circle class="w-5 h-5 text-cyan-400" />
|
||||
<% "retryable" -> %>
|
||||
<Icons.arrow_path class="w-5 h-5 text-yellow-400" />
|
||||
<% "cancelled" -> %>
|
||||
<Icons.x_circle class="w-5 h-5 text-violet-400" />
|
||||
<% "discarded" -> %>
|
||||
<Icons.exclamation_circle class="w-5 h-5 text-rose-400" />
|
||||
<% _ -> %>
|
||||
<Icons.minus_circle class="w-5 h-5 text-gray-400" />
|
||||
<% end %>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
# Event Handlers
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def handle_event("cancel-workflow", _params, socket) do
|
||||
send(self(), {:cancel_workflow, socket.assigns.workflow.id})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("retry-workflow", _params, socket) do
|
||||
send(self(), {:retry_workflow, socket.assigns.workflow.id})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("toggle-graph", _params, socket) do
|
||||
graph_open? = not socket.assigns[:graph_open?]
|
||||
|
||||
socket = assign(socket, :graph_open?, graph_open?)
|
||||
socket = if graph_open?, do: push_graph_data(socket), else: socket
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("toggle-subs", _params, socket) do
|
||||
{:noreply, assign(socket, :subs_open?, not socket.assigns[:subs_open?])}
|
||||
end
|
||||
|
||||
def handle_event("navigate-to-job", %{"job_id" => job_id}, socket) do
|
||||
{:noreply, push_navigate(socket, to: oban_path([:jobs, job_id]))}
|
||||
end
|
||||
|
||||
def handle_event("navigate-to-workflow", %{"workflow_id" => workflow_id}, socket) do
|
||||
{:noreply, push_navigate(socket, to: oban_path([:workflows, workflow_id]))}
|
||||
end
|
||||
|
||||
def handle_event("expand-sub-workflow", %{"workflow_id" => sub_workflow_id}, socket) do
|
||||
%{jobs: jobs, truncated: truncated} =
|
||||
WorkflowQuery.get_sub_workflow_jobs(socket.assigns.conf, sub_workflow_id)
|
||||
|
||||
payload = %{workflow_id: sub_workflow_id, jobs: jobs, truncated: truncated}
|
||||
socket = push_event(socket, "sub-workflow-jobs", payload)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Helpers
|
||||
|
||||
defp cancel_tooltip(true), do: "Cancel all jobs in this workflow"
|
||||
defp cancel_tooltip(false), do: "Cancel requires Oban Pro"
|
||||
|
||||
defp retry_tooltip(true), do: "Retry failed jobs in this workflow"
|
||||
defp retry_tooltip(false), do: "Retry requires Oban Pro"
|
||||
|
||||
defp has_retryable?(workflow) do
|
||||
workflow.retryable + workflow.discarded + workflow.cancelled > 0
|
||||
end
|
||||
|
||||
defp count_sub_states(subs) do
|
||||
init = Map.new(@states, &{&1, 0})
|
||||
|
||||
Enum.reduce(subs, init, fn sub, acc ->
|
||||
Map.update!(acc, String.to_existing_atom(sub.state), &(&1 + 1))
|
||||
end)
|
||||
end
|
||||
end
|
||||
271
vendor/oban_web/lib/oban/web/live/workflows/table_component.ex
vendored
Normal file
271
vendor/oban_web/lib/oban/web/live/workflows/table_component.ex
vendored
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
defmodule Oban.Web.Workflows.TableComponent do
|
||||
use Oban.Web, :live_component
|
||||
|
||||
import Oban.Web.Helpers, only: [integer_to_estimate: 1, oban_path: 1]
|
||||
|
||||
alias Oban.Web.Timing
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="workflows-table" class="min-w-full">
|
||||
<ul class="flex items-center border-b border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500">
|
||||
<.header label="name" class="pl-3 w-1/3 text-left" />
|
||||
<div class="ml-auto flex items-center space-x-6">
|
||||
<.header label="progress" class="w-64 text-center" />
|
||||
<.header label="activity" class="w-44 text-center" />
|
||||
<.header label="duration" class="w-24 text-right" />
|
||||
<.header label="started" class="w-24 text-right" />
|
||||
<.header label="status" class="w-16 pr-4 text-right" />
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<div :if={Enum.empty?(@workflows)} class="py-16 px-6 text-center">
|
||||
<Icons.rectangle_group class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<h3 class="mt-4 text-xl font-semibold text-gray-900 dark:text-gray-100">No workflows</h3>
|
||||
<p class="mt-2 text-base text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
Workflows coordinate jobs with dependencies. They'll appear here once jobs with workflow metadata are enqueued.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<a
|
||||
href="https://oban.pro/docs/pro/Oban.Pro.Workflow.html"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-base font-medium text-violet-600 hover:text-violet-500 dark:text-violet-400 dark:hover:text-violet-300"
|
||||
>
|
||||
Learn about workflows <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<.workflow_row :for={workflow <- @workflows} workflow={workflow} />
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :label, :string, required: true
|
||||
attr :class, :string, default: ""
|
||||
|
||||
defp header(assigns) do
|
||||
~H"""
|
||||
<span class={[@class, "text-xs font-medium uppercase tracking-wider py-1.5"]}>
|
||||
{@label}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :workflow, :map, required: true
|
||||
|
||||
defp workflow_row(assigns) do
|
||||
~H"""
|
||||
<li id={"workflow-#{@workflow.id}"}>
|
||||
<.link
|
||||
patch={oban_path([:workflows, @workflow.id])}
|
||||
class="flex items-center hover:bg-gray-50 dark:hover:bg-gray-950/30"
|
||||
>
|
||||
<div class="pl-3 py-3.5 flex flex-grow items-center">
|
||||
<div class="w-1/3">
|
||||
<span class="font-semibold text-sm text-gray-700 dark:text-gray-300">
|
||||
{@workflow.display_name}
|
||||
</span>
|
||||
|
||||
<div :if={Enum.any?(@workflow.queues)} class="flex flex-wrap items-center gap-1.5 mt-1">
|
||||
<span
|
||||
:for={queue <- @workflow.queues}
|
||||
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
{queue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center space-x-6 tabular text-gray-500 dark:text-gray-300">
|
||||
<.progress_bar workflow={@workflow} />
|
||||
|
||||
<.activity_counts workflow={@workflow} />
|
||||
|
||||
<.format_duration workflow={@workflow} />
|
||||
|
||||
<.started_at workflow={@workflow} />
|
||||
|
||||
<div class="w-16 pr-4 flex justify-end">
|
||||
<.status_indicator id={@workflow.id} state={@workflow.state} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</.link>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :workflow, :map, required: true
|
||||
|
||||
defp progress_bar(assigns) do
|
||||
activity = assigns.workflow.activity
|
||||
total = assigns.workflow.total
|
||||
finished = activity.finished
|
||||
percent = if total > 0, do: min(round(finished / total * 100), 100), else: 0
|
||||
|
||||
assigns = assign(assigns, finished: finished, total: total, percent: percent)
|
||||
|
||||
~H"""
|
||||
<div class="w-64 flex items-center">
|
||||
<div class="w-48 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full bg-cyan-400" style={"width: #{@percent}%"} />
|
||||
</div>
|
||||
<span class="w-14 text-left tabular pl-2 text-sm">{@finished}/{@total}</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :workflow, :map, required: true
|
||||
|
||||
defp activity_counts(assigns) do
|
||||
activity = assigns.workflow.activity
|
||||
id = assigns.workflow.id
|
||||
|
||||
assigns =
|
||||
assign(assigns,
|
||||
id: id,
|
||||
suspended: activity.suspended,
|
||||
pending: activity.pending,
|
||||
executing: activity.executing,
|
||||
finished: activity.finished
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div class="w-44 flex items-center justify-end text-sm">
|
||||
<.state_count id={"#{@id}-susp"} count={@suspended} color="gray" title="Suspended" />
|
||||
<.state_count id={"#{@id}-pend"} count={@pending} color="blue" title="Pending" />
|
||||
<.state_count id={"#{@id}-exec"} count={@executing} color="emerald" title="Executing" />
|
||||
<.state_count id={"#{@id}-fin"} count={@finished} color="cyan" title="Finished" />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :count, :integer, required: true
|
||||
attr :color, :string, required: true
|
||||
attr :title, :string, required: true
|
||||
|
||||
defp state_count(assigns) do
|
||||
bg_class =
|
||||
case {assigns.count, assigns.color} do
|
||||
{0, _} -> "bg-gray-300 dark:bg-gray-600"
|
||||
{_, "gray"} -> "bg-gray-400"
|
||||
{_, "blue"} -> "bg-blue-400"
|
||||
{_, "emerald"} -> "bg-emerald-400"
|
||||
{_, "cyan"} -> "bg-cyan-400"
|
||||
end
|
||||
|
||||
assigns = assign(assigns, bg_class: bg_class)
|
||||
|
||||
~H"""
|
||||
<span class="w-11 flex items-center space-x-1.5" data-title={@title} id={@id} phx-hook="Tippy">
|
||||
<span class="flex-1 text-right">{integer_to_estimate(@count)}</span>
|
||||
<span class={["w-2 h-2 rounded-full", @bg_class]} />
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :workflow, :map, required: true
|
||||
|
||||
defp started_at(assigns) do
|
||||
activity = assigns.workflow.activity
|
||||
executed? = activity.executing + activity.finished > 0
|
||||
started = if executed?, do: assigns.workflow.started_at
|
||||
|
||||
assigns = assign(assigns, started: started)
|
||||
|
||||
~H"""
|
||||
<span
|
||||
:if={@started}
|
||||
class="w-24 text-right text-sm"
|
||||
id={"wf-started-#{@workflow.id}"}
|
||||
data-timestamp={DateTime.to_unix(@started, :millisecond)}
|
||||
phx-hook="Relativize"
|
||||
phx-update="ignore"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
<span :if={is_nil(@started)} class="w-24 text-right text-sm">
|
||||
-
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :workflow, :map, required: true
|
||||
|
||||
defp format_duration(assigns) do
|
||||
wf = assigns.workflow
|
||||
executing? = wf.state == :executing
|
||||
started? = not is_nil(wf.started_at)
|
||||
|
||||
duration =
|
||||
if wf.started_at && wf.completed_at do
|
||||
DateTime.diff(wf.completed_at, wf.started_at, :millisecond)
|
||||
end
|
||||
|
||||
formatted =
|
||||
if is_nil(duration) or duration <= 0 do
|
||||
"-"
|
||||
else
|
||||
duration |> div(1000) |> Timing.to_duration()
|
||||
end
|
||||
|
||||
assigns =
|
||||
assign(assigns,
|
||||
executing?: executing?,
|
||||
started?: started?,
|
||||
formatted: formatted,
|
||||
started_at: wf.started_at
|
||||
)
|
||||
|
||||
~H"""
|
||||
<span
|
||||
:if={@executing? and @started?}
|
||||
class="w-24 text-right text-sm"
|
||||
id={"wf-duration-#{@workflow.id}"}
|
||||
data-timestamp={DateTime.to_unix(@started_at, :millisecond)}
|
||||
data-relative-mode="duration"
|
||||
phx-hook="Relativize"
|
||||
phx-update="ignore"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
<span :if={not (@executing? and @started?)} class="w-24 text-right text-sm">
|
||||
{@formatted}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :state, :atom, required: true
|
||||
|
||||
defp status_indicator(assigns) do
|
||||
~H"""
|
||||
<span data-title={status_title(@state)} id={"workflow-state-#{@id}"} phx-hook="Tippy">
|
||||
<%= case @state do %>
|
||||
<% :executing -> %>
|
||||
<Icons.play_circle class="w-5 h-5 text-emerald-400" />
|
||||
<% :completed -> %>
|
||||
<Icons.check_circle class="w-5 h-5 text-cyan-400" />
|
||||
<% :cancelled -> %>
|
||||
<Icons.x_circle class="w-5 h-5 text-violet-400" />
|
||||
<% :discarded -> %>
|
||||
<Icons.exclamation_circle class="w-5 h-5 text-rose-400" />
|
||||
<% _ -> %>
|
||||
<Icons.minus_circle class="w-5 h-5 text-gray-400" />
|
||||
<% end %>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_title(:executing), do: "Executing"
|
||||
defp status_title(:completed), do: "Completed"
|
||||
defp status_title(:cancelled), do: "Cancelled"
|
||||
defp status_title(:discarded), do: "Discarded"
|
||||
defp status_title(_), do: "Unknown"
|
||||
end
|
||||
83
vendor/oban_web/lib/oban/web/metrics.ex
vendored
Normal file
83
vendor/oban_web/lib/oban/web/metrics.ex
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
defmodule Oban.Web.Metrics do
|
||||
@moduledoc false
|
||||
|
||||
alias Oban.Met
|
||||
|
||||
@states ~w(available executing scheduled retryable)a
|
||||
|
||||
@doc """
|
||||
Fetch latest metric counts with optional fallback to previous values.
|
||||
|
||||
When `Met.latest/3` returns an empty map (which happens when the reporter's
|
||||
`check_interval` exceeds the recorder's lookback window), this function
|
||||
returns the `previous` value instead. This prevents UI flickering in the
|
||||
sidebar when metrics briefly show as zero between reporter broadcasts.
|
||||
"""
|
||||
def latest(name, series, opts \\ [], previous \\ %{}) do
|
||||
result = Met.latest(name, series, opts)
|
||||
|
||||
if result == %{} and previous != %{} do
|
||||
previous
|
||||
else
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetch state counts for the jobs sidebar.
|
||||
"""
|
||||
def state_counts(name, ordered_states, previous \\ []) do
|
||||
counts = latest(name, :full_count, [group: "state"], counts_to_map(previous))
|
||||
|
||||
if counts == %{} and previous != [] do
|
||||
previous
|
||||
else
|
||||
for state <- ordered_states do
|
||||
%{name: state, count: Map.get(counts, state, 0)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp counts_to_map(previous) when is_list(previous) do
|
||||
Map.new(previous, fn %{name: name, count: count} -> {name, count} end)
|
||||
end
|
||||
|
||||
defp counts_to_map(previous), do: previous
|
||||
|
||||
@doc """
|
||||
Fetch queue counts for a specific state.
|
||||
"""
|
||||
def queue_counts(name, state, previous \\ %{}) do
|
||||
latest(name, :full_count, [group: "queue", filters: [state: to_string(state)]], previous)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetch state counts filtered by queue for queue detail view.
|
||||
"""
|
||||
def queue_state_counts(name, queue, previous \\ %{}) do
|
||||
latest(name, :full_count, [group: "state", filters: [queue: queue]], previous)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Fetch counts for all queue states needed for the queues sidebar.
|
||||
"""
|
||||
def all_queue_counts(name, previous \\ %{}) do
|
||||
Map.new(@states, fn state ->
|
||||
{state, queue_counts(name, state, previous[state])}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Extract previous counts from a list of queue structs for caching.
|
||||
"""
|
||||
def extract_queue_counts(queues) when is_list(queues) do
|
||||
for state <- @states, into: %{} do
|
||||
per_queue =
|
||||
for queue <- queues, into: %{}, do: {queue.name, Map.get(queue.counts, state, 0)}
|
||||
|
||||
{state, per_queue}
|
||||
end
|
||||
end
|
||||
|
||||
def extract_queue_counts(_), do: %{}
|
||||
end
|
||||
26
vendor/oban_web/lib/oban/web/page.ex
vendored
Normal file
26
vendor/oban_web/lib/oban/web/page.ex
vendored
Normal 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
|
||||
264
vendor/oban_web/lib/oban/web/pages/crons_page.ex
vendored
Normal file
264
vendor/oban_web/lib/oban/web/pages/crons_page.ex
vendored
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
defmodule Oban.Web.CronsPage do
|
||||
@behaviour Oban.Web.Page
|
||||
|
||||
use Oban.Web, :live_component
|
||||
|
||||
alias Oban.Web.{Cron, CronQuery, Page, QueueQuery, SearchComponent, SortComponent, Utils}
|
||||
alias Oban.Web.Crons.{DetailComponent, NewComponent, TableComponent}
|
||||
|
||||
@known_params ~w(limit modes names sort_by sort_dir states workers)
|
||||
|
||||
@inc_limit 20
|
||||
@max_limit 100
|
||||
@min_limit 20
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="crons-page" class="w-full flex flex-col my-6 md:flex-row">
|
||||
<div class="bg-white dark:bg-gray-900 flex-grow rounded-md shadow-lg overflow-hidden">
|
||||
<%= if @detailed do %>
|
||||
<.live_component
|
||||
id="detail"
|
||||
access={@access}
|
||||
conf={@conf}
|
||||
cron={@detailed}
|
||||
history={@detailed.history}
|
||||
module={DetailComponent}
|
||||
params={without_defaults(Map.delete(@params, "id"), @default_params)}
|
||||
queues={QueueQuery.all_queues(%{}, @conf)}
|
||||
resolver={@resolver}
|
||||
/>
|
||||
<% else %>
|
||||
<div
|
||||
id="crons-header"
|
||||
class="pr-3 py-3 flex items-center border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex-none flex items-center px-3">
|
||||
<h2 class="text-lg dark:text-gray-200 leading-4 font-bold">Crons</h2>
|
||||
</div>
|
||||
|
||||
<.live_component
|
||||
conf={@conf}
|
||||
id="search"
|
||||
module={SearchComponent}
|
||||
page={:crons}
|
||||
params={without_defaults(@params, @default_params)}
|
||||
queryable={CronQuery}
|
||||
resolver={@resolver}
|
||||
/>
|
||||
|
||||
<div class="pl-3 ml-auto flex items-center">
|
||||
<SortComponent.select
|
||||
id="crons-sort"
|
||||
by={~w(name worker schedule last_run next_run)}
|
||||
page={:crons}
|
||||
params={@params}
|
||||
/>
|
||||
|
||||
<.link
|
||||
:if={Utils.has_crons?(@conf)}
|
||||
patch={can?(:insert_crons, @access) && oban_path([:crons, :new])}
|
||||
id="new-cron-button"
|
||||
data-title="Create a new dynamic cron"
|
||||
phx-hook="Tippy"
|
||||
aria-disabled={not can?(:insert_crons, @access)}
|
||||
class={[
|
||||
"ml-3 h-10 flex items-center text-sm bg-white dark:bg-gray-800 px-3 py-2 border rounded-md",
|
||||
can?(:insert_crons, @access) &&
|
||||
"text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-700 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 focus-visible:border-blue-500 hover:text-blue-500 hover:border-blue-600 cursor-pointer",
|
||||
not can?(:insert_crons, @access) &&
|
||||
"text-gray-400 dark:text-gray-500 border-gray-200 dark:border-gray-800 cursor-not-allowed opacity-50"
|
||||
]}
|
||||
>
|
||||
<Icons.plus_circle class="mr-1 h-4 w-4" /> New
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.live_component id="crons-table" module={TableComponent} crontab={@crontab} />
|
||||
|
||||
<div
|
||||
:if={@show_less? or @show_more?}
|
||||
class="py-6 flex items-center justify-center space-x-6"
|
||||
>
|
||||
<.load_button label="Show Less" click="load-less" active={@show_less?} myself={@myself} />
|
||||
<.load_button label="Show More" click="load-more" active={@show_more?} myself={@myself} />
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<.live_component
|
||||
:if={@show_new_form}
|
||||
id="new-cron-form"
|
||||
access={@access}
|
||||
conf={@conf}
|
||||
module={NewComponent}
|
||||
queues={QueueQuery.all_queues(%{}, @conf)}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Components
|
||||
|
||||
attr :active, :boolean, required: true
|
||||
attr :click, :string, required: true
|
||||
attr :label, :string, required: true
|
||||
attr :myself, :any, required: true
|
||||
|
||||
defp load_button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
type="button"
|
||||
class={"font-semibold text-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 #{loader_class(@active)}"}
|
||||
phx-target={@myself}
|
||||
phx-click={@click}
|
||||
>
|
||||
{@label}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp loader_class(true) do
|
||||
"""
|
||||
text-gray-700 dark:text-gray-300 cursor-pointer transition ease-in-out duration-200 border-b
|
||||
border-gray-200 dark:border-gray-800 hover:border-gray-400
|
||||
"""
|
||||
end
|
||||
|
||||
defp loader_class(_), do: "text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
|
||||
@impl Page
|
||||
def handle_mount(socket) do
|
||||
default = %{limit: @min_limit, sort_by: "worker", sort_dir: "asc"}
|
||||
|
||||
socket
|
||||
|> assign(:default_params, default)
|
||||
|> assign_new(:detailed, fn -> nil end)
|
||||
|> assign_new(:params, fn -> default end)
|
||||
|> assign_new(:crontab, fn -> [] end)
|
||||
|> assign_new(:show_less?, fn -> false end)
|
||||
|> assign_new(:show_more?, fn -> false end)
|
||||
end
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:show_new_form, fn -> false end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl Page
|
||||
def handle_refresh(%{assigns: %{detailed: nil}} = socket) do
|
||||
%{params: params, conf: conf} = socket.assigns
|
||||
|
||||
crons = CronQuery.all_crons(params, conf)
|
||||
limit = params.limit
|
||||
|
||||
assign(socket,
|
||||
crontab: crons,
|
||||
show_less?: limit > @min_limit,
|
||||
show_more?: limit < @max_limit and length(crons) == limit
|
||||
)
|
||||
end
|
||||
|
||||
def handle_refresh(socket) do
|
||||
%{conf: conf, detailed: detailed} = socket.assigns
|
||||
|
||||
case detailed do
|
||||
%Cron{name: name} -> assign(socket, detailed: CronQuery.get_cron(name, conf))
|
||||
_ -> socket
|
||||
end
|
||||
end
|
||||
|
||||
@impl Page
|
||||
def handle_params(%{"id" => "new"} = params, _uri, socket) do
|
||||
params =
|
||||
params
|
||||
|> Map.take(@known_params)
|
||||
|> decode_params()
|
||||
|> then(&Map.merge(socket.assigns.default_params, &1))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(detailed: nil, show_new_form: true, page_title: page_title("New Cron"))
|
||||
|> assign(params: params)}
|
||||
end
|
||||
|
||||
def handle_params(%{"id" => cron_name} = params, _uri, socket) do
|
||||
params =
|
||||
params
|
||||
|> Map.take(@known_params)
|
||||
|> decode_params()
|
||||
|> then(&Map.merge(socket.assigns.default_params, &1))
|
||||
|
||||
case CronQuery.get_cron(cron_name, socket.assigns.conf) do
|
||||
nil ->
|
||||
{:noreply, push_patch(socket, to: oban_path(:crons), replace: true)}
|
||||
|
||||
cron ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(detailed: cron, show_new_form: false, page_title: page_title(cron.worker))
|
||||
|> assign(params: params)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_params(params, _uri, socket) do
|
||||
params =
|
||||
params
|
||||
|> Map.take(@known_params)
|
||||
|> decode_params()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(detailed: nil, show_new_form: false, page_title: page_title("Crons"))
|
||||
|> assign(params: Map.merge(socket.assigns.default_params, params))
|
||||
|> handle_refresh()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def handle_event("load-less", _params, socket) do
|
||||
if socket.assigns.show_less? do
|
||||
send(self(), {:params, :limit, -@inc_limit})
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("load-more", _params, socket) do
|
||||
if socket.assigns.show_more? do
|
||||
send(self(), {:params, :limit, @inc_limit})
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl Page
|
||||
def handle_info({:params, :limit, inc}, socket) when is_integer(inc) do
|
||||
params =
|
||||
socket.assigns.params
|
||||
|> Map.update!(:limit, &(&1 + inc))
|
||||
|> without_defaults(socket.assigns.default_params)
|
||||
|
||||
{:noreply, push_patch(socket, to: oban_path(:crons, params), replace: true)}
|
||||
end
|
||||
|
||||
def handle_info({:flash, mode, message}, socket) do
|
||||
{:noreply, put_flash_with_clear(socket, mode, message)}
|
||||
end
|
||||
|
||||
def handle_info(:refresh, socket) do
|
||||
{:noreply, handle_refresh(socket)}
|
||||
end
|
||||
|
||||
def handle_info(_event, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
602
vendor/oban_web/lib/oban/web/pages/jobs_page.ex
vendored
Normal file
602
vendor/oban_web/lib/oban/web/pages/jobs_page.ex
vendored
Normal 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
|
||||
510
vendor/oban_web/lib/oban/web/pages/queues_page.ex
vendored
Normal file
510
vendor/oban_web/lib/oban/web/pages/queues_page.ex
vendored
Normal 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
|
||||
336
vendor/oban_web/lib/oban/web/pages/workflows_page.ex
vendored
Normal file
336
vendor/oban_web/lib/oban/web/pages/workflows_page.ex
vendored
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
defmodule Oban.Web.WorkflowsPage do
|
||||
@behaviour Oban.Web.Page
|
||||
|
||||
use Oban.Web, :live_component
|
||||
|
||||
alias Oban.Pro.Workflow
|
||||
alias Oban.Web.{Page, SearchComponent, SortComponent, Telemetry, Utils, WorkflowQuery}
|
||||
alias Oban.Web.Workflows.{DetailComponent, TableComponent}
|
||||
|
||||
@compile {:no_warn_undefined, Oban.Pro.Workflow}
|
||||
|
||||
@known_params ~w(ids limit names queues sort_by sort_dir states workers)
|
||||
|
||||
@keep_on_mount ~w(
|
||||
default_params
|
||||
detail
|
||||
sub_workflows
|
||||
graph_data
|
||||
params
|
||||
parent_workflow
|
||||
workflow
|
||||
workflows
|
||||
)a
|
||||
|
||||
@inc_limit 20
|
||||
@max_limit 100
|
||||
@min_limit 20
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="workflows-page" class="w-full my-6">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-md shadow-lg overflow-hidden">
|
||||
<%= cond do %>
|
||||
<% not @has_workflows? -> %>
|
||||
<.pro_promo />
|
||||
<% @detail -> %>
|
||||
<.live_component
|
||||
id="detail"
|
||||
access={@access}
|
||||
conf={@conf}
|
||||
module={DetailComponent}
|
||||
pro_available?={@pro_available?}
|
||||
workflow={@workflow}
|
||||
parent_workflow={@parent_workflow}
|
||||
sub_workflows={@sub_workflows}
|
||||
graph_data={@graph_data}
|
||||
/>
|
||||
<% true -> %>
|
||||
<div
|
||||
id="workflows-header"
|
||||
class="pr-3 py-3 flex items-center border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex-none flex items-center px-3">
|
||||
<h2 class="text-lg dark:text-gray-200 leading-4 font-bold">Workflows</h2>
|
||||
</div>
|
||||
|
||||
<.live_component
|
||||
conf={@conf}
|
||||
id="search"
|
||||
module={SearchComponent}
|
||||
page={:workflows}
|
||||
params={without_defaults(@params, @default_params)}
|
||||
queryable={WorkflowQuery}
|
||||
resolver={@resolver}
|
||||
/>
|
||||
|
||||
<div class="pl-3 ml-auto flex items-center">
|
||||
<SortComponent.select
|
||||
id="workflows-sort"
|
||||
by={~w(inserted started duration total progress)}
|
||||
page={:workflows}
|
||||
params={@params}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.live_component id="workflows-table" module={TableComponent} workflows={@workflows} />
|
||||
|
||||
<div
|
||||
:if={@show_less? or @show_more?}
|
||||
class="py-6 flex items-center justify-center space-x-6 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<.load_button label="Show Less" click="load-less" active={@show_less?} myself={@myself} />
|
||||
<.load_button label="Show More" click="load-more" active={@show_more?} myself={@myself} />
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :active, :boolean, required: true
|
||||
attr :click, :string, required: true
|
||||
attr :label, :string, required: true
|
||||
attr :myself, :any, required: true
|
||||
|
||||
defp load_button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
type="button"
|
||||
class={"font-semibold text-sm focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500 #{loader_class(@active)}"}
|
||||
phx-target={@myself}
|
||||
phx-click={@click}
|
||||
>
|
||||
{@label}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp loader_class(true) do
|
||||
"""
|
||||
text-gray-700 dark:text-gray-300 cursor-pointer transition ease-in-out duration-200 border-b
|
||||
border-gray-200 dark:border-gray-800 hover:border-gray-400
|
||||
"""
|
||||
end
|
||||
|
||||
defp loader_class(_), do: "text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
|
||||
defp pro_promo(assigns) do
|
||||
~H"""
|
||||
<div class="flex flex-col items-center justify-center py-16 px-6">
|
||||
<div class="flex items-center justify-center w-16 h-16 rounded-full bg-violet-100 dark:bg-violet-900/30 mb-6">
|
||||
<Icons.rectangle_group class="w-8 h-8 text-violet-500 dark:text-violet-400" />
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Workflows
|
||||
</h2>
|
||||
|
||||
<p class="text-center text-gray-600 dark:text-gray-400 max-w-3xl mb-6">
|
||||
Orchestrate jobs with dependencies for sequential execution, fan-out parallelization, and
|
||||
fan-in convergence. Build fault-tolerant processing pipelines that scale horizontally
|
||||
across all nodes.
|
||||
</p>
|
||||
|
||||
<ul class="text-left text-gray-600 dark:text-gray-400 space-y-3 mb-8">
|
||||
<li class="flex items-start">
|
||||
<Icons.check class="w-5 h-5 text-violet-500 mr-2 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">Fully Distributed</span>
|
||||
— high availability and scalability across your infrastructure
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<Icons.check class="w-5 h-5 text-violet-500 mr-2 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">Cascading Context</span>
|
||||
— pass cumulative context between jobs for seamless data flow
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<Icons.check class="w-5 h-5 text-violet-500 mr-2 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">Nested Sub-Workflows</span>
|
||||
— compose hierarchically for better organization and reusability
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<.link
|
||||
href="https://oban.pro"
|
||||
target="_blank"
|
||||
class="inline-flex items-center px-5 py-2.5 rounded-lg bg-violet-600 hover:bg-violet-700 text-white font-medium transition-colors"
|
||||
>
|
||||
Learn about Oban Pro <Icons.arrow_top_right_on_square class="w-4 h-4 ml-2" />
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl Page
|
||||
def handle_mount(socket) do
|
||||
default = %{limit: @min_limit, sort_by: "inserted", sort_dir: "desc"}
|
||||
|
||||
assigns = Map.drop(socket.assigns, @keep_on_mount)
|
||||
|
||||
%{socket | assigns: assigns}
|
||||
|> assign(:default_params, default)
|
||||
|> assign(:has_workflows?, Utils.has_workflows?(socket.assigns.conf))
|
||||
|> assign(:pro_available?, Utils.has_pro?())
|
||||
|> assign_new(:detail, fn -> nil end)
|
||||
|> assign_new(:sub_workflows, fn -> [] end)
|
||||
|> assign_new(:params, fn -> default end)
|
||||
|> assign_new(:parent_workflow, fn -> nil end)
|
||||
|> assign_new(:show_less?, fn -> false end)
|
||||
|> assign_new(:show_more?, fn -> false end)
|
||||
|> assign_new(:workflow, fn -> nil end)
|
||||
|> assign_new(:workflows, fn -> [] end)
|
||||
end
|
||||
|
||||
@impl Page
|
||||
def handle_refresh(socket) do
|
||||
%{params: params, conf: conf, detail: detail, has_workflows?: has_workflows?} = socket.assigns
|
||||
|
||||
cond do
|
||||
not has_workflows? ->
|
||||
socket
|
||||
|
||||
detail ->
|
||||
workflow = WorkflowQuery.get_workflow(conf, detail)
|
||||
sub_workflows = WorkflowQuery.get_sub_workflows(conf, detail)
|
||||
parent_workflow = WorkflowQuery.get_sup_workflow(conf, detail)
|
||||
graph_data = WorkflowQuery.get_workflow_graph(conf, detail)
|
||||
|
||||
assign(socket,
|
||||
workflow: workflow,
|
||||
sub_workflows: sub_workflows,
|
||||
parent_workflow: parent_workflow,
|
||||
graph_data: graph_data
|
||||
)
|
||||
|
||||
true ->
|
||||
workflows = WorkflowQuery.all_workflows(params, conf)
|
||||
limit = params.limit
|
||||
|
||||
assign(socket,
|
||||
workflows: workflows,
|
||||
show_less?: limit > @min_limit,
|
||||
show_more?: limit < @max_limit and length(workflows) == limit
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@impl Page
|
||||
def handle_params(%{"id" => workflow_id}, _uri, socket) do
|
||||
conf = socket.assigns.conf
|
||||
|
||||
workflow = WorkflowQuery.get_workflow(conf, workflow_id)
|
||||
sub_workflows = WorkflowQuery.get_sub_workflows(conf, workflow_id)
|
||||
parent_workflow = WorkflowQuery.get_sup_workflow(conf, workflow_id)
|
||||
graph_data = WorkflowQuery.get_workflow_graph(conf, workflow_id)
|
||||
|
||||
title = if workflow, do: workflow.name || workflow_id, else: "Workflow"
|
||||
|
||||
socket =
|
||||
assign(socket,
|
||||
detail: workflow_id,
|
||||
workflow: workflow,
|
||||
sub_workflows: sub_workflows,
|
||||
parent_workflow: parent_workflow,
|
||||
graph_data: graph_data,
|
||||
page_title: page_title(title)
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_params(params, _uri, socket) do
|
||||
params =
|
||||
params
|
||||
|> Map.take(@known_params)
|
||||
|> decode_params()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(page_title: page_title("Workflows"))
|
||||
|> assign(detail: nil, params: Map.merge(socket.assigns.default_params, params))
|
||||
|> handle_refresh()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl Phoenix.LiveComponent
|
||||
def handle_event("load-less", _params, socket) do
|
||||
if socket.assigns.show_less? do
|
||||
send(self(), {:params, :limit, -@inc_limit})
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("load-more", _params, socket) do
|
||||
if socket.assigns.show_more? do
|
||||
send(self(), {:params, :limit, @inc_limit})
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl Page
|
||||
def handle_info({:params, :limit, inc}, socket) when is_integer(inc) do
|
||||
params =
|
||||
socket.assigns.params
|
||||
|> Map.update!(:limit, &(&1 + inc))
|
||||
|> without_defaults(socket.assigns.default_params)
|
||||
|
||||
{:noreply, push_patch(socket, to: oban_path(:workflows, params), replace: true)}
|
||||
end
|
||||
|
||||
def handle_info(:refresh, socket) do
|
||||
{:noreply, handle_refresh(socket)}
|
||||
end
|
||||
|
||||
def handle_info({:cancel_workflow, workflow_id}, socket) do
|
||||
enforce_access!(:cancel_workflows, socket.assigns.access)
|
||||
|
||||
socket =
|
||||
if Utils.has_pro?() do
|
||||
Telemetry.action(:cancel_workflow, socket, [workflow_id: workflow_id], fn ->
|
||||
Workflow.cancel_jobs(socket.assigns.conf.name, workflow_id)
|
||||
end)
|
||||
|
||||
socket
|
||||
|> handle_refresh()
|
||||
|> put_flash_with_clear(:info, "Workflow jobs cancelled")
|
||||
else
|
||||
put_flash_with_clear(socket, :error, "Cancel requires Oban Pro")
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:retry_workflow, workflow_id}, socket) do
|
||||
enforce_access!(:retry_workflows, socket.assigns.access)
|
||||
|
||||
socket =
|
||||
if Utils.has_pro?() do
|
||||
Telemetry.action(:retry_workflow, socket, [workflow_id: workflow_id], fn ->
|
||||
Workflow.retry_jobs(socket.assigns.conf.name, workflow_id)
|
||||
end)
|
||||
|
||||
socket
|
||||
|> handle_refresh()
|
||||
|> put_flash_with_clear(:info, "Workflow jobs retried")
|
||||
else
|
||||
put_flash_with_clear(socket, :error, "Retry requires Oban Pro")
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info(_event, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
20
vendor/oban_web/lib/oban/web/plugins/stats.ex
vendored
Normal file
20
vendor/oban_web/lib/oban/web/plugins/stats.ex
vendored
Normal 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
|
||||
353
vendor/oban_web/lib/oban/web/queries/cron_query.ex
vendored
Normal file
353
vendor/oban_web/lib/oban/web/queries/cron_query.ex
vendored
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
defmodule Oban.Web.CronQuery do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
import Oban.Web.QueryHelpers
|
||||
|
||||
alias Oban.Cron.Expression
|
||||
alias Oban.{Job, Met, Repo}
|
||||
alias Oban.Web.{Cron, Search, Utils}
|
||||
|
||||
@compile {:no_warn_undefined, Oban.Pro.Cron}
|
||||
|
||||
@suggest_qualifier [
|
||||
{"names:", "cron entry name", "names:my-cron"},
|
||||
{"workers:", "cron worker name", "workers:MyApp.Worker"},
|
||||
{"modes:", "cron mode (static/dynamic)", "modes:static"},
|
||||
{"states:", "last execution state", "states:completed"}
|
||||
]
|
||||
|
||||
@suggest_state [
|
||||
{"available", "last job is available", "available"},
|
||||
{"cancelled", "last job was cancelled", "cancelled"},
|
||||
{"completed", "last job was completed", "completed"},
|
||||
{"discarded", "last job was discarded", "discarded"},
|
||||
{"executing", "last job is executing", "executing"},
|
||||
{"retryable", "last job is retryable", "retryable"},
|
||||
{"scheduled", "last job is scheduled", "scheduled"},
|
||||
{"unknown", "no previous jobs available", "unknown"}
|
||||
]
|
||||
|
||||
@suggest_mode [
|
||||
{"static", "regular cron job", "static"},
|
||||
{"dynamic", "dynamic cron job", "dynamic"}
|
||||
]
|
||||
|
||||
@known_qualifiers MapSet.new(@suggest_qualifier, fn {qualifier, _, _} -> qualifier end)
|
||||
|
||||
# Searching
|
||||
|
||||
def filterable, do: ~w(names workers states modes)a
|
||||
|
||||
def parse(terms) when is_binary(terms) do
|
||||
Search.parse(terms, &parse_term/1)
|
||||
end
|
||||
|
||||
def suggest(terms, conf, _opts \\ []) do
|
||||
terms
|
||||
|> String.split(~r/\s+(?=([^\"]*\"[^\"]*\")*[^\"]*$)/)
|
||||
|> List.last()
|
||||
|> to_string()
|
||||
|> case do
|
||||
"" ->
|
||||
@suggest_qualifier
|
||||
|
||||
last ->
|
||||
case String.split(last, ":", parts: 2) do
|
||||
["names", frag] -> suggest_names(frag, conf)
|
||||
["workers", frag] -> suggest_workers(frag, conf)
|
||||
["modes", frag] -> suggest_static(frag, @suggest_mode)
|
||||
["states", frag] -> suggest_static(frag, @suggest_state)
|
||||
[frag] -> suggest_static(frag, @suggest_qualifier)
|
||||
_ -> []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp suggest_static(fragment, possibilities) do
|
||||
for {field, _, _} = suggest <- possibilities,
|
||||
String.starts_with?(field, fragment),
|
||||
do: suggest
|
||||
end
|
||||
|
||||
defp suggest_names(fragment, conf) do
|
||||
static_names =
|
||||
conf.name
|
||||
|> Met.crontab()
|
||||
|> Enum.map(&entry_name/1)
|
||||
|
||||
dynamic_names =
|
||||
if Utils.has_crons?(conf) do
|
||||
query = from c in Oban.Pro.Cron, select: c.name
|
||||
Repo.all(conf, query)
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
Search.restrict_suggestions(static_names ++ dynamic_names, fragment)
|
||||
end
|
||||
|
||||
defp suggest_workers(fragment, conf) do
|
||||
conf.name
|
||||
|> Met.crontab()
|
||||
|> Enum.map(&elem(&1, 1))
|
||||
|> Enum.map(&to_string/1)
|
||||
|> Search.restrict_suggestions(fragment)
|
||||
end
|
||||
|
||||
def append(terms, choice) do
|
||||
Search.append(terms, choice, @known_qualifiers)
|
||||
end
|
||||
|
||||
def complete(terms, conf) do
|
||||
case suggest(terms, conf) do
|
||||
[] ->
|
||||
terms
|
||||
|
||||
[{match, _, _} | _] ->
|
||||
append(terms, match)
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_term("names:" <> names) do
|
||||
{:names, String.split(names, ",")}
|
||||
end
|
||||
|
||||
defp parse_term("workers:" <> workers) do
|
||||
{:workers, String.split(workers, ",")}
|
||||
end
|
||||
|
||||
defp parse_term("states:" <> states) do
|
||||
parsed =
|
||||
states
|
||||
|> String.split(",")
|
||||
|> Enum.map(fn
|
||||
"unknown" -> nil
|
||||
state -> state
|
||||
end)
|
||||
|
||||
{:states, parsed}
|
||||
end
|
||||
|
||||
defp parse_term("modes:" <> modes) do
|
||||
{:modes, String.split(modes, ",")}
|
||||
end
|
||||
|
||||
defp parse_term(_term), do: {:none, ""}
|
||||
|
||||
@history_limit 60
|
||||
|
||||
# Querying
|
||||
|
||||
def all_crons(params, conf) do
|
||||
{sort_by, sort_dir} = parse_sort(params)
|
||||
limit = Map.get(params, :limit, 20)
|
||||
|
||||
crontab = static_crontab(conf) ++ dynamic_crontab(conf)
|
||||
history = crontab_history(crontab, conf)
|
||||
conditions = Map.take(params, filterable())
|
||||
|
||||
crontab
|
||||
|> Enum.map(&build_cron(&1, history))
|
||||
|> Enum.filter(&filter(&1, conditions))
|
||||
|> Enum.sort_by(&order(&1, sort_by), sort_dir)
|
||||
|> Enum.take(limit)
|
||||
end
|
||||
|
||||
def get_cron(name, conf) when is_binary(name) do
|
||||
with entry when not is_nil(entry) <- find_cron_entry(name, conf) do
|
||||
history = cron_history(name, conf)
|
||||
build_cron(entry, %{name => history})
|
||||
end
|
||||
end
|
||||
|
||||
defp find_cron_entry(name, conf) do
|
||||
static_entry =
|
||||
conf
|
||||
|> static_crontab()
|
||||
|> Enum.find(fn {_, _, _, cron_name, _, _} -> cron_name == name end)
|
||||
|
||||
cond do
|
||||
static_entry ->
|
||||
static_entry
|
||||
|
||||
Utils.has_crons?(conf) ->
|
||||
query =
|
||||
from c in Oban.Pro.Cron,
|
||||
where: c.name == ^name,
|
||||
select: {c.expression, c.worker, c.opts, c.name, true, c.paused},
|
||||
limit: 1
|
||||
|
||||
Repo.one(conf, query)
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def cron_history(name, conf) do
|
||||
query =
|
||||
Job
|
||||
|> where(^filter_cron_name(name, conf))
|
||||
|> order_by([j], desc: j.id)
|
||||
|> limit(@history_limit)
|
||||
|> select([j], %{
|
||||
state: j.state,
|
||||
scheduled_at: j.scheduled_at,
|
||||
attempted_at: j.attempted_at,
|
||||
finished_at: fragment("COALESCE(?, ?, ?)", j.completed_at, j.cancelled_at, j.discarded_at)
|
||||
})
|
||||
|
||||
conf
|
||||
|> Repo.all(query)
|
||||
|> Enum.reverse()
|
||||
end
|
||||
|
||||
defp filter_cron_name(name, conf) when is_mysql(conf) or is_sqlite(conf) do
|
||||
dynamic([j], fragment("json_extract(?, '$.cron_name') = ?", j.meta, ^name))
|
||||
end
|
||||
|
||||
defp filter_cron_name(name, _conf) do
|
||||
dynamic([j], fragment("? @> ?", j.meta, ^%{cron_name: name}))
|
||||
end
|
||||
|
||||
defp static_crontab(conf) do
|
||||
conf.name
|
||||
|> Met.crontab()
|
||||
|> Enum.map(fn {expr, worker, opts} = entry ->
|
||||
{expr, worker, opts, entry_name(entry), false, false}
|
||||
end)
|
||||
end
|
||||
|
||||
defp entry_name({_expr, _worker, opts} = entry) do
|
||||
Map.get_lazy(opts, "name", fn -> Oban.Plugins.Cron.entry_name(entry) end)
|
||||
end
|
||||
|
||||
defp dynamic_crontab(conf) do
|
||||
if Utils.has_crons?(conf) do
|
||||
query = select(Oban.Pro.Cron, [c], {c.expression, c.worker, c.opts, c.name, true, c.paused})
|
||||
|
||||
Repo.all(conf, query)
|
||||
end
|
||||
end
|
||||
|
||||
# Construction
|
||||
|
||||
defp build_cron({expr, worker, opts, name, dynamic?, paused?}, history) do
|
||||
jobs = Map.get(history, name, [])
|
||||
last_job = List.last(jobs)
|
||||
|
||||
fields = [
|
||||
name: name,
|
||||
expression: expr,
|
||||
worker: worker,
|
||||
opts: opts,
|
||||
dynamic?: dynamic?,
|
||||
paused?: paused?,
|
||||
next_at: next_at(expr),
|
||||
last_at: last_at_from_job(last_job),
|
||||
last_state: if(last_job, do: last_job.state),
|
||||
history: jobs
|
||||
]
|
||||
|
||||
struct!(Cron, fields)
|
||||
end
|
||||
|
||||
defp last_at_from_job(nil), do: nil
|
||||
defp last_at_from_job(%{finished_at: at}) when not is_nil(at), do: at
|
||||
defp last_at_from_job(%{attempted_at: at}) when not is_nil(at), do: at
|
||||
defp last_at_from_job(%{scheduled_at: at}) when not is_nil(at), do: at
|
||||
defp last_at_from_job(_job), do: nil
|
||||
|
||||
def crontab_history(crontab, conf) when is_mysql(conf) or is_sqlite(conf) do
|
||||
crontab
|
||||
|> Enum.map(&elem(&1, 3))
|
||||
|> Map.new(fn name -> {name, cron_history(name, conf)} end)
|
||||
end
|
||||
|
||||
def crontab_history(crontab, conf) do
|
||||
names = Enum.map(crontab, &elem(&1, 3))
|
||||
|
||||
inside =
|
||||
from o in Job,
|
||||
where:
|
||||
fragment("? @> jsonb_build_object('cron_name', ?)", o.meta, parent_as(:list).value),
|
||||
select: %{
|
||||
cron_name: o.meta["cron_name"],
|
||||
state: o.state,
|
||||
attempted_at: o.attempted_at,
|
||||
scheduled_at: o.scheduled_at,
|
||||
finished_at:
|
||||
fragment("COALESCE(?, ?, ?)", o.completed_at, o.cancelled_at, o.discarded_at)
|
||||
},
|
||||
select_merge: %{
|
||||
rn: over(row_number(), partition_by: o.meta["cron_name"], order_by: [desc: o.id])
|
||||
}
|
||||
|
||||
ranked = from t in subquery(inside), where: t.rn <= @history_limit
|
||||
|
||||
query =
|
||||
from f in fragment("json_array_elements_text(?)", ^names),
|
||||
as: :list,
|
||||
left_lateral_join: j in subquery(ranked),
|
||||
on: true,
|
||||
order_by: [asc: j.rn],
|
||||
select: {f.value, j}
|
||||
|
||||
conf
|
||||
|> Repo.all(query)
|
||||
|> Enum.group_by(&elem(&1, 0), fn {_name, job} -> job end)
|
||||
|> Map.new(fn {name, jobs} ->
|
||||
{name, jobs |> Enum.reject(&empty_job?/1) |> Enum.reverse()}
|
||||
end)
|
||||
end
|
||||
|
||||
defp empty_job?(nil), do: true
|
||||
defp empty_job?(%{scheduled_at: nil}), do: true
|
||||
defp empty_job?(_job), do: false
|
||||
|
||||
defp next_at(expression) do
|
||||
expression
|
||||
|> Expression.parse!()
|
||||
|> Expression.next_at()
|
||||
end
|
||||
|
||||
# Sorting
|
||||
|
||||
defp parse_sort(%{sort_by: "last_run", sort_dir: dir}) do
|
||||
{:last_run, {String.to_existing_atom(dir), NaiveDateTime}}
|
||||
end
|
||||
|
||||
defp parse_sort(%{sort_by: "next_run", sort_dir: dir}) do
|
||||
{:next_run, {String.to_existing_atom(dir), NaiveDateTime}}
|
||||
end
|
||||
|
||||
defp parse_sort(%{sort_by: sby, sort_dir: dir}) do
|
||||
{String.to_existing_atom(sby), String.to_existing_atom(dir)}
|
||||
end
|
||||
|
||||
defp parse_sort(_params), do: {:worker, :asc}
|
||||
|
||||
defp order(%{last_at: nil}, :last_run), do: ~U[2000-01-01 00:00:00Z]
|
||||
defp order(%{last_at: last_at}, :last_run), do: last_at
|
||||
defp order(%{name: name}, :name), do: name
|
||||
defp order(%{next_at: next_at}, :next_run), do: next_at
|
||||
defp order(%{expression: expression}, :schedule), do: expression
|
||||
defp order(%{worker: worker}, :worker), do: worker
|
||||
|
||||
# Filtering
|
||||
|
||||
defp filter(_row, conditions) when conditions == %{}, do: true
|
||||
|
||||
defp filter(row, conditions) when is_map(conditions) do
|
||||
Enum.all?(conditions, &filter(row, &1))
|
||||
end
|
||||
|
||||
defp filter(cron, {:names, names}), do: cron.name in names
|
||||
defp filter(cron, {:workers, workers}), do: cron.worker in workers
|
||||
defp filter(cron, {:states, states}), do: cron.last_state in states
|
||||
|
||||
defp filter(cron, {:modes, modes}) do
|
||||
if(cron.dynamic?, do: "dynamic", else: "static") in modes
|
||||
end
|
||||
end
|
||||
710
vendor/oban_web/lib/oban/web/queries/job_query.ex
vendored
Normal file
710
vendor/oban_web/lib/oban/web/queries/job_query.ex
vendored
Normal 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
|
||||
47
vendor/oban_web/lib/oban/web/queries/query_helpers.ex
vendored
Normal file
47
vendor/oban_web/lib/oban/web/queries/query_helpers.ex
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
defmodule Oban.Web.QueryHelpers do
|
||||
@moduledoc false
|
||||
|
||||
# Engine Guards
|
||||
|
||||
defguard is_mysql(conf) when conf.engine == Oban.Engines.Dolphin
|
||||
|
||||
defguard is_sqlite(conf) when conf.engine == Oban.Engines.Lite
|
||||
|
||||
# Type Extraction
|
||||
|
||||
defmacro mysql_extract_type(field, path) do
|
||||
quote do
|
||||
fragment("lower(json_type(json_extract(?, ?)))", unquote(field), unquote(path))
|
||||
end
|
||||
end
|
||||
|
||||
defmacro sqlite_extract_type(field, path) do
|
||||
quote do
|
||||
fragment("json_type(?->?)", unquote(field), unquote(path))
|
||||
end
|
||||
end
|
||||
|
||||
defmacro postgres_extract_type(field, path) do
|
||||
quote do
|
||||
fragment("jsonb_typeof(?#>?)", unquote(field), unquote(path))
|
||||
end
|
||||
end
|
||||
|
||||
# Array Containment
|
||||
|
||||
defmacro sqlite_contains_any(column, list) do
|
||||
quote do
|
||||
fragment(
|
||||
"""
|
||||
exists (
|
||||
select 1
|
||||
from json_each(?) as t1, json_each(?) as t2
|
||||
where t1.value = t2.value
|
||||
)
|
||||
""",
|
||||
unquote(column),
|
||||
^Oban.JSON.encode!(unquote(list))
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
196
vendor/oban_web/lib/oban/web/queries/queue_query.ex
vendored
Normal file
196
vendor/oban_web/lib/oban/web/queries/queue_query.ex
vendored
Normal 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
|
||||
473
vendor/oban_web/lib/oban/web/queries/workflow_query.ex
vendored
Normal file
473
vendor/oban_web/lib/oban/web/queries/workflow_query.ex
vendored
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
defmodule Oban.Web.WorkflowQuery do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Oban.{Job, Repo}
|
||||
alias Oban.Web.{Search, Workflow}
|
||||
|
||||
@default_sup_graph_limit 500
|
||||
@default_sub_graph_limit 100
|
||||
@default_suggest_limit 100
|
||||
|
||||
defmacrop has_workflow_id(meta, workflow_id) do
|
||||
quote do
|
||||
fragment(
|
||||
"? \\? 'workflow_id' AND ?->>'workflow_id' = ?",
|
||||
unquote(meta),
|
||||
unquote(meta),
|
||||
unquote(workflow_id)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defmacrop has_sup_workflow_id(meta, workflow_id) do
|
||||
quote do
|
||||
fragment(
|
||||
"? \\? 'sup_workflow_id' AND ?->>'sup_workflow_id' = ?",
|
||||
unquote(meta),
|
||||
unquote(meta),
|
||||
unquote(workflow_id)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defmacrop duration_seconds(completed_at, started_at) do
|
||||
quote do
|
||||
fragment(
|
||||
"EXTRACT(EPOCH FROM (COALESCE(?, NOW()) - ?))",
|
||||
unquote(completed_at),
|
||||
unquote(started_at)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defmacrop total_jobs(wf) do
|
||||
quote do
|
||||
fragment(
|
||||
"? + ? + ? + ? + ? + ? + ? + ?",
|
||||
unquote(wf).suspended,
|
||||
unquote(wf).available,
|
||||
unquote(wf).scheduled,
|
||||
unquote(wf).executing,
|
||||
unquote(wf).retryable,
|
||||
unquote(wf).completed,
|
||||
unquote(wf).cancelled,
|
||||
unquote(wf).discarded
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defmacrop progress_percent(wf) do
|
||||
quote do
|
||||
fragment(
|
||||
"?::float / NULLIF(? + ? + ? + ? + ? + ? + ? + ?, 0)",
|
||||
unquote(wf).completed,
|
||||
unquote(wf).suspended,
|
||||
unquote(wf).available,
|
||||
unquote(wf).scheduled,
|
||||
unquote(wf).executing,
|
||||
unquote(wf).retryable,
|
||||
unquote(wf).completed,
|
||||
unquote(wf).cancelled,
|
||||
unquote(wf).discarded
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defmacrop job_graph_select(job) do
|
||||
quote do
|
||||
%{
|
||||
id: unquote(job).id,
|
||||
state: unquote(job).state,
|
||||
worker: unquote(job).worker,
|
||||
meta:
|
||||
fragment(
|
||||
"""
|
||||
jsonb_build_object(
|
||||
'name', ?->>'name',
|
||||
'deps', ?->'deps',
|
||||
'workflow_name', ?->>'workflow_name',
|
||||
'decorated_name', ?->>'decorated_name',
|
||||
'handler', ?->>'handler',
|
||||
'context', (?->>'context')::boolean
|
||||
)
|
||||
""",
|
||||
unquote(job).meta,
|
||||
unquote(job).meta,
|
||||
unquote(job).meta,
|
||||
unquote(job).meta,
|
||||
unquote(job).meta,
|
||||
unquote(job).meta
|
||||
)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defmacrop aggregate_workflow_state(state) do
|
||||
quote do
|
||||
fragment(
|
||||
"""
|
||||
CASE
|
||||
WHEN bool_or(? = 'executing') THEN 'executing'
|
||||
WHEN bool_or(? = 'discarded') THEN 'discarded'
|
||||
WHEN bool_or(? = 'cancelled') THEN 'cancelled'
|
||||
WHEN bool_or(? = 'retryable') THEN 'retryable'
|
||||
WHEN bool_and(? = 'completed') THEN 'completed'
|
||||
ELSE 'pending'
|
||||
END
|
||||
""",
|
||||
unquote(state),
|
||||
unquote(state),
|
||||
unquote(state),
|
||||
unquote(state),
|
||||
unquote(state)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defmacrop sub_workflow_parent_dep(sub_workflow_id, sup_workflow_id) do
|
||||
quote do
|
||||
fragment(
|
||||
"""
|
||||
(SELECT dep->>1
|
||||
FROM oban_jobs j2,
|
||||
LATERAL jsonb_array_elements(j2.meta->'deps') AS dep
|
||||
WHERE j2.meta->>'workflow_id' = ?
|
||||
AND jsonb_typeof(dep) = 'array'
|
||||
AND dep->>0 = ?
|
||||
LIMIT 1)
|
||||
""",
|
||||
unquote(sub_workflow_id),
|
||||
unquote(sup_workflow_id)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defmacrop sub_workflow_states(parent_id) do
|
||||
quote do
|
||||
fragment(
|
||||
"""
|
||||
(SELECT COALESCE(array_agg(state), '{}') FROM oban_workflows WHERE parent_id = ?)
|
||||
""",
|
||||
unquote(parent_id)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@suggest_qualifier [
|
||||
{"ids:", "workflow id", "ids:01234567-89ab-cdef"},
|
||||
{"names:", "workflow name", "names:order-fulfillment"},
|
||||
{"queues:", "queue name", "queues:default"},
|
||||
{"workers:", "worker module", "workers:MyApp.Worker"},
|
||||
{"states:", "workflow state", "states:executing"}
|
||||
]
|
||||
|
||||
@suggest_state [
|
||||
{"executing", "workflow is running", "states:executing"},
|
||||
{"completed", "workflow finished successfully", "states:completed"},
|
||||
{"cancelled", "workflow was cancelled", "states:cancelled"},
|
||||
{"discarded", "workflow has discarded jobs", "states:discarded"}
|
||||
]
|
||||
|
||||
@known_qualifiers MapSet.new(@suggest_qualifier, fn {qualifier, _, _} -> qualifier end)
|
||||
|
||||
# Searching
|
||||
|
||||
def filterable, do: ~w(ids names queues workers states)a
|
||||
|
||||
def parse(terms) when is_binary(terms) do
|
||||
Search.parse(terms, &parse_term/1)
|
||||
end
|
||||
|
||||
def suggest(terms, conf, _opts \\ []) do
|
||||
terms
|
||||
|> String.split(~r/\s+(?=([^\"]*\"[^\"]*\")*[^\"]*$)/)
|
||||
|> List.last()
|
||||
|> to_string()
|
||||
|> case do
|
||||
"" ->
|
||||
@suggest_qualifier
|
||||
|
||||
last ->
|
||||
case String.split(last, ":", parts: 2) do
|
||||
["ids", _frag] -> []
|
||||
["names", frag] -> suggest_names(frag, conf)
|
||||
["queues", frag] -> suggest_queues(frag, conf)
|
||||
["workers", frag] -> suggest_workers(frag, conf)
|
||||
["states", frag] -> suggest_static(frag, @suggest_state)
|
||||
[frag] -> suggest_static(frag, @suggest_qualifier)
|
||||
_ -> []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp suggest_static(fragment, possibilities) do
|
||||
for {field, _, _} = suggest <- possibilities,
|
||||
String.starts_with?(field, fragment),
|
||||
do: suggest
|
||||
end
|
||||
|
||||
defp suggest_names(fragment, conf) do
|
||||
query =
|
||||
Workflow
|
||||
|> select([wf], wf.name)
|
||||
|> distinct(true)
|
||||
|> limit(@default_suggest_limit)
|
||||
|
||||
conf
|
||||
|> Repo.all(query)
|
||||
|> Search.restrict_suggestions(fragment)
|
||||
end
|
||||
|
||||
defp suggest_queues(fragment, conf) do
|
||||
query =
|
||||
Workflow
|
||||
|> select([wf], fragment("jsonb_array_elements_text(?->'queues')", wf.meta))
|
||||
|> distinct(true)
|
||||
|> limit(@default_suggest_limit)
|
||||
|
||||
conf
|
||||
|> Repo.all(query)
|
||||
|> Search.restrict_suggestions(fragment)
|
||||
end
|
||||
|
||||
defp suggest_workers(fragment, conf) do
|
||||
query =
|
||||
Workflow
|
||||
|> select([wf], fragment("jsonb_array_elements_text(?->'workers')", wf.meta))
|
||||
|> distinct(true)
|
||||
|> limit(@default_suggest_limit)
|
||||
|
||||
conf
|
||||
|> Repo.all(query)
|
||||
|> Search.restrict_suggestions(fragment)
|
||||
end
|
||||
|
||||
def append(terms, choice) do
|
||||
Search.append(terms, choice, @known_qualifiers)
|
||||
end
|
||||
|
||||
def complete(terms, conf) do
|
||||
case suggest(terms, conf) do
|
||||
[] -> terms
|
||||
[{match, _, _} | _] -> append(terms, match)
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_term("ids:" <> ids) do
|
||||
{:ids, String.split(ids, ",")}
|
||||
end
|
||||
|
||||
defp parse_term("names:" <> names) do
|
||||
{:names, String.split(names, ",")}
|
||||
end
|
||||
|
||||
defp parse_term("queues:" <> queues) do
|
||||
{:queues, String.split(queues, ",")}
|
||||
end
|
||||
|
||||
defp parse_term("workers:" <> workers) do
|
||||
{:workers, String.split(workers, ",")}
|
||||
end
|
||||
|
||||
defp parse_term("states:" <> states) do
|
||||
{:states, String.split(states, ",")}
|
||||
end
|
||||
|
||||
defp parse_term(_term), do: {:none, ""}
|
||||
|
||||
# Querying
|
||||
|
||||
def all_workflows(params, conf) do
|
||||
limit = Map.get(params, :limit, 10)
|
||||
sort_by = Map.get(params, :sort_by, "inserted")
|
||||
sort_dir = Map.get(params, :sort_dir, "desc")
|
||||
dir = String.to_existing_atom(sort_dir)
|
||||
|
||||
Workflow
|
||||
|> where([wf], is_nil(wf.parent_id))
|
||||
|> apply_filters(params)
|
||||
|> apply_sort(sort_by, dir)
|
||||
|> limit(^limit)
|
||||
|> select([wf], {wf, sub_workflow_states(wf.id)})
|
||||
|> then(&Repo.all(conf, &1))
|
||||
|> Enum.map(&build_workflow/1)
|
||||
end
|
||||
|
||||
defp apply_filters(query, params) do
|
||||
query
|
||||
|> filter_by_ids(Map.get(params, :ids))
|
||||
|> filter_by_names(Map.get(params, :names))
|
||||
|> filter_by_states(Map.get(params, :states))
|
||||
|> filter_by_queues(Map.get(params, :queues))
|
||||
|> filter_by_workers(Map.get(params, :workers))
|
||||
end
|
||||
|
||||
defp filter_by_ids(query, nil), do: query
|
||||
defp filter_by_ids(query, ids), do: where(query, [wf], wf.id in ^ids)
|
||||
|
||||
defp filter_by_names(query, nil), do: query
|
||||
defp filter_by_names(query, names), do: where(query, [wf], wf.name in ^names)
|
||||
|
||||
defp filter_by_states(query, nil), do: query
|
||||
defp filter_by_states(query, states), do: where(query, [wf], wf.state in ^states)
|
||||
|
||||
defp filter_by_queues(query, nil), do: query
|
||||
|
||||
defp filter_by_queues(query, queues) do
|
||||
where(query, [wf], fragment("?->'queues' \\?| ?", wf.meta, ^queues))
|
||||
end
|
||||
|
||||
defp filter_by_workers(query, nil), do: query
|
||||
|
||||
defp filter_by_workers(query, workers) do
|
||||
where(query, [wf], fragment("?->'workers' \\?| ?", wf.meta, ^workers))
|
||||
end
|
||||
|
||||
defp apply_sort(query, "inserted", dir) do
|
||||
order_by(query, [wf], [{^dir, wf.inserted_at}])
|
||||
end
|
||||
|
||||
defp apply_sort(query, "started", dir) do
|
||||
order_by(query, [wf], [{^dir, wf.started_at}])
|
||||
end
|
||||
|
||||
defp apply_sort(query, "duration", dir) do
|
||||
order_by(query, [wf], [{^dir, duration_seconds(wf.completed_at, wf.started_at)}])
|
||||
end
|
||||
|
||||
defp apply_sort(query, "total", dir) do
|
||||
order_by(query, [wf], [{^dir, total_jobs(wf)}])
|
||||
end
|
||||
|
||||
defp apply_sort(query, "progress", dir) do
|
||||
order_by(query, [wf], [{^dir, progress_percent(wf)}])
|
||||
end
|
||||
|
||||
defp apply_sort(query, _unknown, dir) do
|
||||
apply_sort(query, "inserted", dir)
|
||||
end
|
||||
|
||||
defp build_workflow({%Workflow{} = wf, sub_states}) do
|
||||
pending = wf.available + wf.scheduled + wf.retryable
|
||||
finished = wf.completed + wf.cancelled + wf.discarded
|
||||
sub_activity = count_sub_activity(sub_states)
|
||||
|
||||
%{
|
||||
id: wf.id,
|
||||
name: wf.name,
|
||||
state: String.to_existing_atom(wf.state),
|
||||
total: wf.suspended + pending + wf.executing + finished + length(sub_states),
|
||||
activity: %{
|
||||
suspended: wf.suspended + sub_activity.suspended,
|
||||
pending: pending + sub_activity.pending,
|
||||
executing: wf.executing + sub_activity.executing,
|
||||
finished: finished + sub_activity.finished
|
||||
},
|
||||
started_at: wf.started_at,
|
||||
completed_at: wf.completed_at,
|
||||
queues: Map.get(wf.meta, "queues", []),
|
||||
display_name: wf.name || wf.id
|
||||
}
|
||||
end
|
||||
|
||||
defp count_sub_activity(states) do
|
||||
Enum.reduce(states, %{suspended: 0, pending: 0, executing: 0, finished: 0}, fn state, acc ->
|
||||
case state do
|
||||
"suspended" ->
|
||||
%{acc | suspended: acc.suspended + 1}
|
||||
|
||||
"executing" ->
|
||||
%{acc | executing: acc.executing + 1}
|
||||
|
||||
state when state in ~w(completed cancelled discarded) ->
|
||||
%{acc | finished: acc.finished + 1}
|
||||
|
||||
_ ->
|
||||
%{acc | pending: acc.pending + 1}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def get_workflow(conf, workflow_id) do
|
||||
query = where(Workflow, [wf], wf.id == ^workflow_id)
|
||||
|
||||
Repo.one(conf, query)
|
||||
end
|
||||
|
||||
def get_sup_workflow(conf, workflow_id) do
|
||||
Workflow
|
||||
|> join(:inner, [child], parent in Workflow, on: child.parent_id == parent.id)
|
||||
|> where([child, _parent], child.id == ^workflow_id)
|
||||
|> select([_child, parent], parent)
|
||||
|> then(&Repo.one(conf, &1))
|
||||
end
|
||||
|
||||
def get_sub_workflows(conf, workflow_id, limit \\ 10) do
|
||||
Workflow
|
||||
|> where([wf], wf.parent_id == ^workflow_id)
|
||||
|> limit(^limit)
|
||||
|> then(&Repo.all(conf, &1))
|
||||
end
|
||||
|
||||
def get_sub_workflow_jobs(conf, sub_workflow_id, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, @default_sub_graph_limit)
|
||||
|
||||
query =
|
||||
Job
|
||||
|> where([j], has_workflow_id(j.meta, ^sub_workflow_id))
|
||||
|> order_by([j], asc: j.id)
|
||||
|> limit(^limit)
|
||||
|> select([j], job_graph_select(j))
|
||||
|
||||
jobs = Repo.all(conf, query)
|
||||
|
||||
%{jobs: jobs, truncated: length(jobs) >= limit}
|
||||
end
|
||||
|
||||
def get_workflow_graph(conf, workflow_id, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, @default_sup_graph_limit)
|
||||
jobs = workflow_graph_jobs(conf, workflow_id, limit)
|
||||
subs = workflow_graph_subs(conf, workflow_id)
|
||||
|
||||
%{jobs: jobs, sub_workflows: subs, truncated: length(jobs) >= limit}
|
||||
end
|
||||
|
||||
defp workflow_graph_jobs(conf, workflow_id, limit) do
|
||||
query =
|
||||
Job
|
||||
|> where([j], has_workflow_id(j.meta, ^workflow_id))
|
||||
|> select([j], job_graph_select(j))
|
||||
|> order_by([j], asc: j.id)
|
||||
|> limit(^limit)
|
||||
|
||||
Repo.all(conf, query)
|
||||
end
|
||||
|
||||
defp workflow_graph_subs(conf, workflow_id) do
|
||||
base_query =
|
||||
Job
|
||||
|> where([j], has_sup_workflow_id(j.meta, ^workflow_id))
|
||||
|> group_by([j], fragment("?->>'workflow_id'", j.meta))
|
||||
|> select([j], %{
|
||||
workflow_id: fragment("?->>'workflow_id'", j.meta),
|
||||
workflow_name: fragment("MAX(?->>'workflow_name')", j.meta),
|
||||
sub_name: fragment("MAX(?->>'sub_name')", j.meta),
|
||||
state: aggregate_workflow_state(j.state)
|
||||
})
|
||||
|
||||
query =
|
||||
from(s in subquery(base_query),
|
||||
select: %{
|
||||
workflow_id: s.workflow_id,
|
||||
workflow_name: s.workflow_name,
|
||||
sub_name: s.sub_name,
|
||||
state: s.state,
|
||||
parent_dep: sub_workflow_parent_dep(s.workflow_id, ^workflow_id)
|
||||
}
|
||||
)
|
||||
|
||||
Repo.all(conf, query)
|
||||
end
|
||||
end
|
||||
68
vendor/oban_web/lib/oban/web/queue.ex
vendored
Normal file
68
vendor/oban_web/lib/oban/web/queue.ex
vendored
Normal 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
506
vendor/oban_web/lib/oban/web/resolver.ex
vendored
Normal 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
330
vendor/oban_web/lib/oban/web/router.ex
vendored
Normal 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
80
vendor/oban_web/lib/oban/web/search.ex
vendored
Normal 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
|
||||
183
vendor/oban_web/lib/oban/web/telemetry.ex
vendored
Normal file
183
vendor/oban_web/lib/oban/web/telemetry.ex
vendored
Normal 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
|
||||
21
vendor/oban_web/lib/oban/web/timezones.ex
vendored
Normal file
21
vendor/oban_web/lib/oban/web/timezones.ex
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Oban.Web.Timezones do
|
||||
@moduledoc false
|
||||
|
||||
database = :oban_web |> :code.priv_dir() |> Path.join("timezones.txt")
|
||||
|
||||
@external_resource database
|
||||
|
||||
@timezones database
|
||||
|> File.read!()
|
||||
|> String.split("\n", trim: true)
|
||||
|
||||
def all, do: @timezones
|
||||
|
||||
def options do
|
||||
Enum.map(all(), &{&1, &1})
|
||||
end
|
||||
|
||||
def options_with_blank do
|
||||
[{"", ""} | options()]
|
||||
end
|
||||
end
|
||||
276
vendor/oban_web/lib/oban/web/timing.ex
vendored
Normal file
276
vendor/oban_web/lib/oban/web/timing.ex
vendored
Normal 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
36
vendor/oban_web/lib/oban/web/utils.ex
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
defmodule Oban.Web.Utils do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Oban.Repo
|
||||
|
||||
def has_crons?(conf), do: has_table?(conf, "oban_crons")
|
||||
|
||||
def has_workflows?(conf), do: has_table?(conf, "oban_workflows")
|
||||
|
||||
def has_pro? do
|
||||
persistent_cache(:pro?, fn -> Code.ensure_loaded?(Oban.Pro) end)
|
||||
end
|
||||
|
||||
def persistent_cache(key, fun) when is_function(fun, 0) do
|
||||
case :persistent_term.get(key, nil) do
|
||||
nil -> tap(fun.(), &:persistent_term.put(key, &1))
|
||||
val -> val
|
||||
end
|
||||
end
|
||||
|
||||
defp has_table?(conf, table_name) do
|
||||
%{name: name, prefix: prefix} = conf
|
||||
|
||||
persistent_cache({:table?, name, table_name}, fn ->
|
||||
query =
|
||||
from("tables")
|
||||
|> put_query_prefix("information_schema")
|
||||
|> where(table_schema: ^prefix, table_name: ^table_name)
|
||||
|> select(true)
|
||||
|
||||
Repo.one(conf, query) == true
|
||||
end)
|
||||
end
|
||||
end
|
||||
35
vendor/oban_web/lib/oban/web/workflow.ex
vendored
Normal file
35
vendor/oban_web/lib/oban/web/workflow.ex
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
defmodule Oban.Web.Workflow do
|
||||
@moduledoc false
|
||||
|
||||
use Ecto.Schema
|
||||
|
||||
@fields ~w(
|
||||
id name parent_id inserted_at meta
|
||||
suspended available scheduled executing retryable completed cancelled discarded
|
||||
)a
|
||||
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
schema "oban_workflows" do
|
||||
field :name, :string
|
||||
field :parent_id, :string
|
||||
field :state, :string
|
||||
field :meta, :map
|
||||
|
||||
field :suspended, :integer, default: 0
|
||||
field :available, :integer, default: 0
|
||||
field :scheduled, :integer, default: 0
|
||||
field :executing, :integer, default: 0
|
||||
field :retryable, :integer, default: 0
|
||||
field :completed, :integer, default: 0
|
||||
field :cancelled, :integer, default: 0
|
||||
field :discarded, :integer, default: 0
|
||||
|
||||
field :started_at, :utc_datetime_usec
|
||||
field :completed_at, :utc_datetime_usec
|
||||
field :inserted_at, :utc_datetime_usec
|
||||
end
|
||||
|
||||
def changeset(params) do
|
||||
Ecto.Changeset.cast(%__MODULE__{}, params, @fields)
|
||||
end
|
||||
end
|
||||
176
vendor/oban_web/mix.exs
vendored
Normal file
176
vendor/oban_web/mix.exs
vendored
Normal 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
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
187
vendor/oban_web/priv/static/app.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
vendor/oban_web/priv/static/fonts/Inter.woff2
vendored
Normal file
BIN
vendor/oban_web/priv/static/fonts/Inter.woff2
vendored
Normal file
Binary file not shown.
3
vendor/oban_web/priv/static/icons/outline/adjustments-horizontal.svg
vendored
Normal file
3
vendor/oban_web/priv/static/icons/outline/adjustments-horizontal.svg
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 380 B |
3
vendor/oban_web/priv/static/icons/outline/adjustments-vertical.svg
vendored
Normal file
3
vendor/oban_web/priv/static/icons/outline/adjustments-vertical.svg
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 13.5V3.75m0 9.75a1.5 1.5 0 010 3m0-3a1.5 1.5 0 000 3m0 3.75V16.5m12-3V3.75m0 9.75a1.5 1.5 0 010 3m0-3a1.5 1.5 0 000 3m0 3.75V16.5m-6-9V3.75m0 3.75a1.5 1.5 0 010 3m0-3a1.5 1.5 0 000 3m0 9.75V10.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
3
vendor/oban_web/priv/static/icons/outline/arrow-left.svg
vendored
Normal file
3
vendor/oban_web/priv/static/icons/outline/arrow-left.svg
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 211 B |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue