wallflower 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba9a1d426696f5a091d18e4266c1ef2e5dfcb8166e110b3e143e3417fa055af7
4
- data.tar.gz: '09abd80d4d1f08ec0a38bbe7ef19b5ae75601631da08edc3125cb6027a987097'
3
+ metadata.gz: 777814642256dec3caef9316ece5411d3ae340dd7073f3b56258b2a79804603c
4
+ data.tar.gz: 19545678d642b13d461f26408fc8f8844d3dd79fccaddf10c2b394e892561cef
5
5
  SHA512:
6
- metadata.gz: dc04356f627dcee3e1181778f479a49762ef5b72444238df923dbb37e8a71f710d1327eeffc0020fbe84e447c08088baac6e0c3b2e8f8b995ee147b1ab26035b
7
- data.tar.gz: 8e98021b01854edc0548b4f8f73eaff8759c97bd572bfadf951d685cc169fe3d7cfbfe71bb441619d68da7a7e03e55ac3a3311c39386a632f43b6a48d180875d
6
+ metadata.gz: 1fbef5b2d7cf042c5c4c6063726696520396fc4fba134ae5dfda7ce4456b5d25c904e26fe1efd1a718f7a555c58e36a163f77cc41008acaa21ec78108ee63778
7
+ data.tar.gz: 345abf578eb99a1043797466ad1c5c81d29f91086bd4ec1497c24d20bdb95b2551cfff6925409ea7ebe7afb1b09360b23fd4e57bb4d1a0691b4ebfbe1888b60b
data/README.md CHANGED
@@ -4,7 +4,7 @@ Background task tracking for Rails apps: a person starts long-running work, foll
4
4
 
5
5
  ## Installation
6
6
 
7
- Add the gem and run its install generator, which copies a migration for its tasks table:
7
+ Add the gem and run its install generator, which copies the migrations for its tables and a Stimulus controller to `app/javascript/controllers/wallflower/catch_up_controller.js`:
8
8
 
9
9
  ```ruby
10
10
  gem "wallflower"
@@ -16,6 +16,13 @@ bin/rails generate wallflower:install
16
16
  bin/rails db:migrate
17
17
  ```
18
18
 
19
+ After updating the gem, run its update generator to copy any migrations added since it was installed and the latest copy of its Stimulus controller, then migrate:
20
+
21
+ ```bash
22
+ bin/rails generate wallflower:update
23
+ bin/rails db:migrate
24
+ ```
25
+
19
26
  Mount the engine at the path its pages should live under:
20
27
 
21
28
  ```ruby
@@ -63,6 +70,22 @@ A runner that produces a file attaches it as the task's result:
63
70
  task.attach_result(io: StringIO.new(csv), filename: "transactions.csv")
64
71
  ```
65
72
 
73
+ A runner whose result is a page in the app, such as a report, links the task to it:
74
+
75
+ ```ruby
76
+ task.link_result(console_report_path(report))
77
+ ```
78
+
79
+ A finished task's page then offers an Open report button. The page itself stays the app's to draw.
80
+
81
+ A runner that works through rows can refuse one it cannot take, with a label for the row and a reason:
82
+
83
+ ```ruby
84
+ task.refuse(label: "Row 4", reason: "Amount is missing")
85
+ ```
86
+
87
+ Call `task.advance` for every row, refused or not. `advance` saves at most once a second, so calling it for every row of a large task does not slow it down, and the final count is saved when the task finishes. A finished task's page shows how many rows were changed (rows done less rows refused) and how many were refused, and lists each refused row with its reason.
88
+
66
89
  A finished task's page then offers a Download button. The file is sent through Wallflower's own route, which gives it only to the person who started the task and answers anyone else with not found.
67
90
 
68
91
  ## Starting a task
@@ -73,8 +96,48 @@ task = Wallflower.start(kind: :export_transactions, person: current_user, accoun
73
96
 
74
97
  The task is returned queued and its job is enqueued. When the job runs, the task is marked running, its runner is called, and when the runner returns the task is marked finished with the time it finished.
75
98
 
99
+ ## When a task finishes
100
+
101
+ Set `on_finish` to run code once with the task when it finishes, such as sending the person a notification through the app's own notification system:
102
+
103
+ ```ruby
104
+ Wallflower.configure do |config|
105
+ config.on_finish = ->(task) { TaskFinishedNotifier.with(task: task).deliver(task.person) }
106
+ end
107
+ ```
108
+
109
+ With no `on_finish` set, tasks still run to the end.
110
+
111
+ ## When a task fails
112
+
113
+ A runner that raises marks its task failed. The task keeps the error's message, its page shows it beside how far the task got, the error is reported through `Rails.error`, and `on_finish` runs once with the failed task.
114
+
115
+ A task still running with no update for longer than `stall_after`, one hour by default, is marked failed by `Wallflower::SweepJob`, with a reason saying it stalled. Schedule the sweep the way the app schedules any recurring job, for example in Solid Queue's `config/recurring.yml`:
116
+
117
+ ```yaml
118
+ production:
119
+ wallflower_sweep:
120
+ class: Wallflower::SweepJob
121
+ schedule: every 10 minutes
122
+ ```
123
+
124
+ ```ruby
125
+ Wallflower.configure do |config|
126
+ config.stall_after = 30.minutes
127
+ config.keep_for = 90.days # unset by default, which keeps every task forever
128
+ end
129
+ ```
130
+
131
+ With `keep_for` set, the same sweep deletes each finished or failed task that ended longer ago than that, with its file and its refused rows. Queued and running tasks stay, however old. With it unset, nothing is ever deleted.
132
+
133
+ ## The task list
134
+
135
+ The engine's root lists the tasks the signed-in person started in the current account, newest first. Each row shows the kind's title, the status as a coloured badge, the progress and when it was started, links to the task's page, and updates while the task runs.
136
+
76
137
  ## The task page
77
138
 
78
139
  A task's page lives at `tasks/:id` under the engine's mount path. The person who started the task sees its kind's title, its status and, once the runner has set a total, a progress bar of done against total. The page listens for changes to its task over Turbo Streams, so its status and progress update without a reload, in a browser and in the Hotwire Native iOS app alike. Anyone else, and the same person viewing from another account, gets a not-found response.
79
140
 
141
+ The task page and the task list reload in place when they are shown again after being hidden, such as when the iOS app returns from the background, so they show changes broadcast while they could not receive them. A page that stays visible makes no extra request. The copied controller is registered by the host's own Stimulus setup, as `wallflower--catch-up`.
142
+
80
143
  Wallflower's pages are drawn with keystone_ui, and live updates need Action Cable and turbo-rails, which the host's layout already loads for Turbo Streams.
@@ -3,6 +3,7 @@
3
3
  module Wallflower
4
4
  class TasksController < ApplicationController
5
5
  def index
6
+ @tasks = visible_tasks.order(created_at: :desc)
6
7
  end
7
8
 
8
9
  def show
@@ -0,0 +1,20 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ this.page = this.element.ownerDocument
6
+ this.catchUp = this.catchUp.bind(this)
7
+ this.page.addEventListener("visibilitychange", this.catchUp)
8
+ }
9
+
10
+ disconnect() {
11
+ this.page.removeEventListener("visibilitychange", this.catchUp)
12
+ }
13
+
14
+ catchUp() {
15
+ if (this.page.visibilityState !== "visible") return
16
+
17
+ const view = this.page.defaultView
18
+ view.Turbo.visit(view.location.href, { action: "replace" })
19
+ }
20
+ }
@@ -3,9 +3,19 @@
3
3
  module Wallflower
4
4
  class RunJob < ActiveJob::Base
5
5
  def perform(task)
6
+ run(task)
7
+ Wallflower.configuration.on_finish&.call(task)
8
+ end
9
+
10
+ private
11
+
12
+ def run(task)
6
13
  task.update!(status: "running")
7
14
  Wallflower.kind(task.kind).runner.constantize.new.call(task)
8
15
  task.update!(status: "finished", finished_at: Time.current)
16
+ rescue StandardError => error
17
+ task.update!(status: "failed", error_message: error.message, finished_at: Time.current)
18
+ Rails.error.report(error, handled: true, context: { wallflower_task_id: task.id })
9
19
  end
10
20
  end
11
21
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallflower
4
+ class SweepJob < ActiveJob::Base
5
+ def perform
6
+ fail_stalled
7
+ delete_old
8
+ end
9
+
10
+ private
11
+
12
+ def delete_old
13
+ keep_for = Wallflower.configuration.keep_for
14
+ return unless keep_for
15
+
16
+ Task.where(status: %w[finished failed]).where(finished_at: ...keep_for.ago).find_each(&:destroy!)
17
+ end
18
+
19
+ def fail_stalled
20
+ Task.where(status: "running").where(updated_at: ...Wallflower.configuration.stall_after.ago).find_each do |task|
21
+ task.update!(status: "failed", error_message: "Stalled: no progress for #{Wallflower.configuration.stall_after.inspect}", finished_at: Time.current)
22
+ Wallflower.configuration.on_finish&.call(task)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallflower
4
+ class Refusal < ApplicationRecord
5
+ self.table_name = "wallflower_refusals"
6
+
7
+ belongs_to :task
8
+ end
9
+ end
@@ -8,11 +8,18 @@ module Wallflower
8
8
  belongs_to :account, polymorphic: true, optional: true
9
9
 
10
10
  has_one_attached :result_file
11
+ has_many :refusals, dependent: :destroy
11
12
 
12
13
  after_update_commit do
13
14
  broadcast_replace_to self, target: ActionView::RecordIdentifier.dom_id(self, :live), partial: "wallflower/tasks/live", locals: { task: self }
14
15
  end
15
16
 
17
+ STATUS_VARIANTS = { "finished" => :success, "failed" => :danger, "running" => :info }.freeze
18
+
19
+ def status_variant
20
+ STATUS_VARIANTS.fetch(status, :neutral)
21
+ end
22
+
16
23
  def kind_title
17
24
  Wallflower.kind(kind).title
18
25
  end
@@ -21,12 +28,30 @@ module Wallflower
21
28
  result_file.attach(io: io, filename: filename)
22
29
  end
23
30
 
31
+ def changed_count
32
+ done - refusals.size
33
+ end
34
+
35
+ def link_result(url)
36
+ update!(result_url: url)
37
+ end
38
+
39
+ def refuse(label:, reason:)
40
+ refusals.create!(label: label, reason: reason)
41
+ end
42
+
24
43
  def set_total(total)
25
44
  update!(total: total)
26
45
  end
27
46
 
47
+ PROGRESS_SAVE_INTERVAL = 1.second
48
+
28
49
  def advance(by = 1)
29
- increment(:done, by).save!
50
+ increment(:done, by)
51
+ return if @progress_saved_at && Time.current - @progress_saved_at < PROGRESS_SAVE_INTERVAL
52
+
53
+ save!
54
+ @progress_saved_at = Time.current
30
55
  end
31
56
  end
32
57
  end
@@ -1,5 +1,8 @@
1
1
  <div id="<%= dom_id(task, :live) %>">
2
- <p id="<%= dom_id(task, :status) %>"><%= task.status.humanize %></p>
2
+ <p id="<%= dom_id(task, :status) %>"><%= render Keystone::Ui::BadgeComponent.new(label: task.status.humanize, variant: task.status_variant) %></p>
3
+ <% if task.status == "failed" %>
4
+ <p id="<%= dom_id(task, :error) %>"><%= task.error_message %></p>
5
+ <% end %>
3
6
  <div id="<%= dom_id(task, :progress) %>">
4
7
  <% if task.total %>
5
8
  <%= render Keystone::Ui::ProgressComponent.new(value: task.done, max: task.total, label: "#{task.done} of #{task.total}") %>
@@ -1 +1,12 @@
1
- <h1>Tasks</h1>
1
+ <%= tag.div(data: { controller: "wallflower--catch-up" }) %>
2
+ <%= ui_page(max_width: :md) do %>
3
+ <h1>Tasks</h1>
4
+ <% @tasks.each do |task| %>
5
+ <div data-task-row>
6
+ <%= turbo_stream_from task %>
7
+ <%= link_to task.kind_title, task_path(task) %>
8
+ <%= render "wallflower/tasks/live", task: task %>
9
+ <p>Started <%= task.created_at.strftime("%b %-d, %Y") %></p>
10
+ </div>
11
+ <% end %>
12
+ <% end %>
@@ -1,7 +1,19 @@
1
1
  <%= turbo_stream_from @task %>
2
+ <%= tag.div(data: { controller: "wallflower--catch-up" }) %>
2
3
  <%= ui_page(max_width: :md) do %>
3
4
  <h1><%= @task.kind_title %></h1>
4
5
  <%= render "wallflower/tasks/live", task: @task %>
6
+ <% if @task.status == "finished" %>
7
+ <p id="<%= dom_id(@task, :outcome) %>"><%= @task.changed_count %> changed, <%= @task.refusals.size %> refused</p>
8
+ <% if @task.refusals.any? %>
9
+ <div id="<%= dom_id(@task, :refusals) %>">
10
+ <%= ui_data_table(items: @task.refusals.order(:id), columns: [ { label: "Row" }, { reason: "Reason" } ]) %>
11
+ </div>
12
+ <% end %>
13
+ <% end %>
14
+ <% if @task.status == "finished" && @task.result_url.present? %>
15
+ <%= ui_button(label: "Open report", href: @task.result_url, variant: :secondary) %>
16
+ <% end %>
5
17
  <% if @task.status == "finished" && @task.result_file.attached? %>
6
18
  <%= ui_button(label: "Download", href: download_task_path(@task), variant: :secondary) %>
7
19
  <% end %>
@@ -10,10 +10,18 @@ module Wallflower
10
10
 
11
11
  source_root File.expand_path("templates", __dir__)
12
12
 
13
- desc "Installs Wallflower: copies its migration."
13
+ desc "Installs Wallflower: copies its migrations and its Stimulus controller."
14
14
 
15
15
  def copy_migration
16
16
  migration_template "create_wallflower_tasks.rb.erb", "db/migrate/create_wallflower_tasks.rb"
17
+ migration_template "create_wallflower_refusals.rb.erb", "db/migrate/create_wallflower_refusals.rb"
18
+ migration_template "add_result_url_to_wallflower_tasks.rb.erb", "db/migrate/add_result_url_to_wallflower_tasks.rb"
19
+ migration_template "add_error_message_to_wallflower_tasks.rb.erb", "db/migrate/add_error_message_to_wallflower_tasks.rb"
20
+ end
21
+
22
+ def copy_catch_up_controller
23
+ source = File.expand_path("../../../../app/javascript/wallflower/catch_up_controller.js", __dir__)
24
+ create_file "app/javascript/controllers/wallflower/catch_up_controller.js", File.read(source)
17
25
  end
18
26
  end
19
27
  end
@@ -0,0 +1,5 @@
1
+ class AddErrorMessageToWallflowerTasks < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_column :wallflower_tasks, :error_message, :text
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class AddResultUrlToWallflowerTasks < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_column :wallflower_tasks, :result_url, :string
4
+ end
5
+ end
@@ -0,0 +1,10 @@
1
+ class CreateWallflowerRefusals < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :wallflower_refusals do |t|
4
+ t.references :task, null: false, foreign_key: { to_table: :wallflower_tasks }
5
+ t.string :label, null: false
6
+ t.string :reason, null: false
7
+ t.timestamps
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Wallflower
7
+ module Generators
8
+ class UpdateGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("../install/templates", __dir__)
12
+
13
+ desc "Updates Wallflower: copies the migrations and the Stimulus controller added since it was installed."
14
+
15
+ def copy_refusals_migration
16
+ migration_template "create_wallflower_refusals.rb.erb", "db/migrate/create_wallflower_refusals.rb"
17
+ end
18
+
19
+ def copy_result_url_migration
20
+ migration_template "add_result_url_to_wallflower_tasks.rb.erb", "db/migrate/add_result_url_to_wallflower_tasks.rb"
21
+ end
22
+
23
+ def copy_error_message_migration
24
+ migration_template "add_error_message_to_wallflower_tasks.rb.erb", "db/migrate/add_error_message_to_wallflower_tasks.rb"
25
+ end
26
+
27
+ def copy_catch_up_controller
28
+ source = File.expand_path("../../../../app/javascript/wallflower/catch_up_controller.js", __dir__)
29
+ create_file "app/javascript/controllers/wallflower/catch_up_controller.js", File.read(source)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -2,12 +2,13 @@
2
2
 
3
3
  module Wallflower
4
4
  class Configuration
5
- attr_accessor :authentication_method, :layout, :current_person_method, :current_account_method
5
+ attr_accessor :authentication_method, :layout, :current_person_method, :current_account_method, :on_finish, :stall_after, :keep_for
6
6
 
7
7
  def initialize
8
8
  @authentication_method = :authenticate_user!
9
9
  @layout = "application"
10
10
  @current_person_method = :current_user
11
+ @stall_after = 1.hour
11
12
  end
12
13
  end
13
14
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wallflower
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wallflower
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Schneider
@@ -92,15 +92,22 @@ files:
92
92
  - Rakefile
93
93
  - app/controllers/wallflower/application_controller.rb
94
94
  - app/controllers/wallflower/tasks_controller.rb
95
+ - app/javascript/wallflower/catch_up_controller.js
95
96
  - app/jobs/wallflower/run_job.rb
97
+ - app/jobs/wallflower/sweep_job.rb
96
98
  - app/models/wallflower/application_record.rb
99
+ - app/models/wallflower/refusal.rb
97
100
  - app/models/wallflower/task.rb
98
101
  - app/views/wallflower/tasks/_live.html.erb
99
102
  - app/views/wallflower/tasks/index.html.erb
100
103
  - app/views/wallflower/tasks/show.html.erb
101
104
  - config/routes.rb
102
105
  - lib/generators/wallflower/install/install_generator.rb
106
+ - lib/generators/wallflower/install/templates/add_error_message_to_wallflower_tasks.rb.erb
107
+ - lib/generators/wallflower/install/templates/add_result_url_to_wallflower_tasks.rb.erb
108
+ - lib/generators/wallflower/install/templates/create_wallflower_refusals.rb.erb
103
109
  - lib/generators/wallflower/install/templates/create_wallflower_tasks.rb.erb
110
+ - lib/generators/wallflower/update/update_generator.rb
104
111
  - lib/wallflower.rb
105
112
  - lib/wallflower/configuration.rb
106
113
  - lib/wallflower/engine.rb