maintenance_on_steroids 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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +267 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +948 -0
  5. data/app/controllers/maintenance_on_steroids/application_controller.rb +66 -0
  6. data/app/controllers/maintenance_on_steroids/dashboard_controller.rb +18 -0
  7. data/app/controllers/maintenance_on_steroids/jobs_controller.rb +59 -0
  8. data/app/controllers/maintenance_on_steroids/runs_controller.rb +223 -0
  9. data/app/jobs/maintenance_on_steroids/run_job.rb +292 -0
  10. data/app/models/maintenance_on_steroids/application_record.rb +6 -0
  11. data/app/models/maintenance_on_steroids/artifact.rb +255 -0
  12. data/app/models/maintenance_on_steroids/run.rb +310 -0
  13. data/app/views/layouts/maintenance_on_steroids/application.html.erb +48 -0
  14. data/app/views/maintenance_on_steroids/dashboard/index.html.erb +163 -0
  15. data/app/views/maintenance_on_steroids/jobs/index.html.erb +35 -0
  16. data/app/views/maintenance_on_steroids/jobs/show.html.erb +107 -0
  17. data/app/views/maintenance_on_steroids/jobs/source.html.erb +79 -0
  18. data/app/views/maintenance_on_steroids/runs/new.html.erb +98 -0
  19. data/app/views/maintenance_on_steroids/runs/show.html.erb +410 -0
  20. data/app/views/maintenance_on_steroids/shared/_auto_refresh.html.erb +85 -0
  21. data/app/views/maintenance_on_steroids/shared/_javascript.html.erb +16 -0
  22. data/app/views/maintenance_on_steroids/shared/_pager.html.erb +20 -0
  23. data/app/views/maintenance_on_steroids/shared/_styles.html.erb +649 -0
  24. data/app/views/maintenance_on_steroids/shared/_task_list_item.html.erb +31 -0
  25. data/config/routes.rb +22 -0
  26. data/lib/generators/maintenance_on_steroids/install/install_generator.rb +56 -0
  27. data/lib/generators/maintenance_on_steroids/install/templates/create_maintenance_on_steroids_tables.rb.erb +56 -0
  28. data/lib/generators/maintenance_on_steroids/install/templates/initializer.rb +42 -0
  29. data/lib/generators/maintenance_on_steroids/job/job_generator.rb +17 -0
  30. data/lib/generators/maintenance_on_steroids/job/templates/job.rb.erb +30 -0
  31. data/lib/maintenance_on_steroids/about_dsl.rb +51 -0
  32. data/lib/maintenance_on_steroids/artifact_dsl.rb +82 -0
  33. data/lib/maintenance_on_steroids/artifacts_proxy.rb +205 -0
  34. data/lib/maintenance_on_steroids/callbacks_dsl.rb +61 -0
  35. data/lib/maintenance_on_steroids/csv_artifact.rb +88 -0
  36. data/lib/maintenance_on_steroids/engine.rb +26 -0
  37. data/lib/maintenance_on_steroids/form_dsl.rb +63 -0
  38. data/lib/maintenance_on_steroids/instrumentation.rb +33 -0
  39. data/lib/maintenance_on_steroids/job_dsl.rb +61 -0
  40. data/lib/maintenance_on_steroids/job_registry.rb +83 -0
  41. data/lib/maintenance_on_steroids/jsonb_artifact.rb +39 -0
  42. data/lib/maintenance_on_steroids/params_proxy.rb +93 -0
  43. data/lib/maintenance_on_steroids/task.rb +109 -0
  44. data/lib/maintenance_on_steroids/text_artifact.rb +74 -0
  45. data/lib/maintenance_on_steroids/version.rb +3 -0
  46. data/lib/maintenance_on_steroids.rb +187 -0
  47. metadata +125 -0
@@ -0,0 +1,255 @@
1
+ module MaintenanceOnSteroids
2
+ class Artifact < ApplicationRecord
3
+ belongs_to :run
4
+
5
+ attr_accessor :worker_token
6
+ around_save :guard_worker_write
7
+
8
+ def guard_worker_write(&block)
9
+ return yield unless worker_token
10
+
11
+ run.worker_token = worker_token
12
+ run.with_execution_lock(&block)
13
+ end
14
+
15
+ def check_buffer_size!(size)
16
+ limit = MaintenanceOnSteroids.max_artifact_size
17
+ return if limit.nil? || size <= limit
18
+
19
+ errors.add(:base, "artifact #{name.inspect} exceeds max_artifact_size (#{limit} bytes); use object storage for large outputs")
20
+ raise ActiveRecord::RecordInvalid, self
21
+ end
22
+
23
+ validates :name, presence: true
24
+ validates :kind, inclusion: { in: %w[input output] }
25
+ validates :artifact_type, inclusion: { in: %w[jsonb blob text csv] }
26
+
27
+ # Cap on how much of an artifact is materialised for an in-page preview.
28
+ # Parsing or HTML-escaping a payload costs several times its size in live
29
+ # objects, so an uncapped preview of a large export OOMs the web process
30
+ # to show the first hundred rows. The full payload is always downloadable.
31
+ # ponytail: the column itself is still SELECTed whole -- if multi-hundred-MB
32
+ # artifacts become normal, push the cap into SQL (substr) and out of Ruby.
33
+ PREVIEW_BYTES = 256 * 1024
34
+
35
+ # Top-level entries kept when previewing an oversized jsonb document.
36
+ # Slicing generated JSON would mean generating all of it first, which is
37
+ # exactly the allocation the byte cap exists to avoid -- so the structure
38
+ # is trimmed before it is rendered, and the byte cap is a second backstop.
39
+ PREVIEW_ENTRIES = 200
40
+
41
+ validate :payload_within_size_limit
42
+
43
+ def data
44
+ case artifact_type
45
+ when "jsonb" then data_jsonb
46
+ when "blob", "csv" then data_blob
47
+ when "text" then data_text
48
+ end
49
+ end
50
+
51
+ def downloadable?
52
+ %w[blob csv].include?(artifact_type) && data_blob.present?
53
+ end
54
+
55
+ # Parsed CSV rows for table preview, capped at PREVIEW_BYTES.
56
+ # nil for non-CSV artifacts.
57
+ def csv_rows
58
+ return nil unless artifact_type == "csv" && data_blob.present?
59
+ require "csv"
60
+ CSV.parse(preview_slice(data_blob))
61
+ rescue CSV::MalformedCSVError
62
+ nil
63
+ end
64
+
65
+ # Capped textual content for inline display; nil for types with no
66
+ # text preview (blob) or with nothing stored yet.
67
+ def preview_text
68
+ case artifact_type
69
+ when "jsonb" then preview_slice(JSON.pretty_generate(previewable_jsonb)) if data_jsonb.present?
70
+ when "text" then preview_slice(data_text) if data_text.present?
71
+ end
72
+ end
73
+
74
+ # True when the inline preview shows less than the whole artifact -- either
75
+ # because the payload is over the byte cap or because a jsonb document had
76
+ # more than PREVIEW_ENTRIES top-level entries.
77
+ def preview_truncated?
78
+ return true if (byte_size || 0) > PREVIEW_BYTES
79
+
80
+ jsonb_over_entry_cap?
81
+ end
82
+
83
+ # Download attributes -- correct MIME and filename even for generated files
84
+ # whose content_type wasn't set explicitly.
85
+ def download_file_name
86
+ file_name.presence || "#{name}.#{artifact_type == 'csv' ? 'csv' : 'bin'}"
87
+ end
88
+
89
+ def download_content_type
90
+ content_type.presence ||
91
+ Rack::Mime.mime_type(::File.extname(download_file_name), "application/octet-stream")
92
+ end
93
+
94
+ def display_value
95
+ case artifact_type
96
+ when "jsonb"
97
+ data_jsonb.present? ? JSON.pretty_generate(data_jsonb) : "{}"
98
+ when "text"
99
+ data_text.to_s
100
+ when "csv"
101
+ data_blob.to_s
102
+ when "blob"
103
+ if file_name.present?
104
+ "#{file_name} (#{human_size})"
105
+ else
106
+ "Binary data (#{human_size})"
107
+ end
108
+ end
109
+ end
110
+
111
+ # Recomputes lightweight stats (entry/line count, byte size, timestamp)
112
+ # into the metadata column. Called on every artifact save so the UI can
113
+ # show size/counts without loading the full payload.
114
+ def refresh_metadata!
115
+ self.metadata = (metadata || {}).merge(computed_metadata)
116
+ end
117
+
118
+ def computed_metadata
119
+ base = { "generated_at" => Time.current.utc.iso8601 }
120
+ case artifact_type
121
+ when "jsonb"
122
+ data = data_jsonb || {}
123
+ base.merge("entries" => (data.respond_to?(:size) ? data.size : 1), "bytes" => data.to_json.bytesize)
124
+ when "text"
125
+ text = data_text || ""
126
+ base.merge("lines" => text.lines.size, "bytes" => text.bytesize)
127
+ when "csv"
128
+ blob = data_blob || ""
129
+ # Newline count == row count (CSV writes a trailing newline per row).
130
+ base.merge("rows" => (blob.empty? ? 0 : blob.count("\n")), "bytes" => blob.bytesize)
131
+ when "blob"
132
+ base.merge("bytes" => (data_blob ? data_blob.bytesize : 0))
133
+ else
134
+ base
135
+ end
136
+ end
137
+
138
+ # Prefers the precomputed metadata byte count; falls back to measuring the
139
+ # column that actually holds this type's payload. The old fallback only
140
+ # looked at data_blob, so jsonb and text rows written before metadata
141
+ # tracking reported nil -- which made preview_truncated? answer false and
142
+ # bypassed every preview cap that depends on it.
143
+ def byte_size
144
+ metadata&.dig("bytes") || measured_byte_size
145
+ end
146
+
147
+ def generated_at
148
+ ts = metadata&.dig("generated_at")
149
+ Time.iso8601(ts) if ts.present?
150
+ rescue ArgumentError
151
+ nil
152
+ end
153
+
154
+ # One-line human summary for the UI: "12 entries · 3.4 KB" etc.
155
+ def summary
156
+ case artifact_type
157
+ when "jsonb"
158
+ "#{metadata&.dig('entries') || 0} entries · #{human_size}"
159
+ when "text"
160
+ "#{metadata&.dig('lines') || 0} lines · #{human_size}"
161
+ when "csv"
162
+ "#{metadata&.dig('rows') || 0} rows · #{human_size}"
163
+ when "blob"
164
+ file_name.present? ? "#{file_name} (#{human_size})" : "Binary data (#{human_size})"
165
+ end
166
+ end
167
+
168
+ # data_jsonb trimmed to PREVIEW_ENTRIES top-level entries. Driven by the
169
+ # entry count itself rather than by preview_truncated?, so a row with no
170
+ # recorded byte size still gets trimmed instead of being generated whole.
171
+ # Scalars and small documents pass through untouched.
172
+ def previewable_jsonb
173
+ case data_jsonb
174
+ when Hash then data_jsonb.size > PREVIEW_ENTRIES ? data_jsonb.first(PREVIEW_ENTRIES).to_h : data_jsonb
175
+ when Array then data_jsonb.size > PREVIEW_ENTRIES ? data_jsonb.first(PREVIEW_ENTRIES) : data_jsonb
176
+ else data_jsonb
177
+ end
178
+ end
179
+
180
+ def jsonb_over_entry_cap?
181
+ artifact_type == "jsonb" &&
182
+ data_jsonb.respond_to?(:size) &&
183
+ !data_jsonb.is_a?(String) &&
184
+ data_jsonb.size > PREVIEW_ENTRIES
185
+ end
186
+
187
+ # One choke point for every write path (explicit save, accumulator flush,
188
+ # file input). Failing the run with a legible message beats an OOM killer
189
+ # or a database-level error halfway through a long task.
190
+ def payload_within_size_limit
191
+ limit = MaintenanceOnSteroids.max_artifact_size
192
+ return if limit.nil?
193
+
194
+ size = measured_byte_size
195
+ return if size.nil? || size <= limit
196
+
197
+ errors.add(
198
+ :base,
199
+ "artifact #{name.inspect} is #{ActiveSupport::NumberHelper.number_to_human_size(size)}, " \
200
+ "over the #{ActiveSupport::NumberHelper.number_to_human_size(limit)} limit " \
201
+ "(MaintenanceOnSteroids.max_artifact_size). Write large outputs to object storage " \
202
+ "and store a reference instead."
203
+ )
204
+ end
205
+
206
+ def measured_byte_size
207
+ case artifact_type
208
+ when "jsonb" then data_jsonb && data_jsonb.to_json.bytesize
209
+ when "text" then data_text&.bytesize
210
+ when "blob", "csv" then data_blob&.bytesize
211
+ end
212
+ end
213
+
214
+ # At most PREVIEW_BYTES of a payload, always returned as valid UTF-8, cut
215
+ # back to the last complete line when it had to be truncated.
216
+ #
217
+ # Two encoding traps here, both of which used to reach the run page:
218
+ # - data_blob comes back as ASCII-8BIT, and interpolating a BINARY string
219
+ # holding non-ASCII bytes into the UTF-8 template raises
220
+ # Encoding::CompatibilityError -- so any CSV export containing an
221
+ # accented character took the page down, at any size.
222
+ # - byteslice cuts on a byte boundary, landing inside a multibyte
223
+ # character often enough to matter, and matching a Regexp against the
224
+ # resulting invalid string raises ArgumentError.
225
+ # Slice first (bounded work), then transcode and scrub the small result.
226
+ def preview_slice(content)
227
+ truncated = content.bytesize > PREVIEW_BYTES
228
+ sliced = truncated ? content.byteslice(0, PREVIEW_BYTES) : content
229
+
230
+ sliced = sliced.dup.force_encoding(Encoding::UTF_8)
231
+ sliced = sliced.scrub("") unless sliced.valid_encoding?
232
+
233
+ return sliced unless truncated
234
+
235
+ # Drop the trailing partial line -- but only when there is an earlier
236
+ # newline to fall back to. A single-line payload (a minified blob, a log
237
+ # with no line breaks) has no newline in the slice at all, and the regex
238
+ # would match the whole thing and leave an empty preview.
239
+ trimmed = sliced.sub(/[^\n]*\z/, "")
240
+ trimmed.empty? ? sliced : trimmed
241
+ end
242
+
243
+ def human_size
244
+ size = byte_size
245
+ return "0 B" unless size && size.positive?
246
+ if size < 1024
247
+ "#{size} B"
248
+ elsif size < 1024 * 1024
249
+ "#{(size / 1024.0).round(1)} KB"
250
+ else
251
+ "#{(size / (1024.0 * 1024)).round(1)} MB"
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,310 @@
1
+ module MaintenanceOnSteroids
2
+ class Run < ApplicationRecord
3
+ has_many :artifacts, dependent: :destroy
4
+
5
+ # Set only on the worker's instance, never accepted from request parameters.
6
+ attr_accessor :worker_token
7
+
8
+ STATUSES = %w[enqueued running pausing paused cancelling cancelled completed errored].freeze
9
+ ACTIVE_STATUSES = %w[enqueued running pausing paused].freeze
10
+ # Statuses an operator may restart from. "errored" is included because a
11
+ # run that failed can resume after its last committed checkpoint, retrying
12
+ # the failed record and any effects it made before checkpointing.
13
+ RESUMABLE_STATUSES = %w[paused errored].freeze
14
+ # Statuses that mean "a worker currently holds this run" -- candidates
15
+ # for staleness reaping when the worker died without updating the row.
16
+ STALE_CANDIDATE_STATUSES = %w[running pausing cancelling].freeze
17
+ # Runs that will never change again, and so are safe to prune.
18
+ TERMINAL_STATUSES = %w[completed cancelled errored].freeze
19
+
20
+ validates :task_class, presence: true
21
+ validates :status, inclusion: { in: STATUSES }
22
+
23
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
24
+ scope :active, -> { where(status: ACTIVE_STATUSES) }
25
+
26
+ STATUSES.each do |s|
27
+ define_method(:"#{s}?") { status == s }
28
+ end
29
+
30
+ # Transitions runs stuck in an in-flight status to "errored" when the row
31
+ # hasn't been touched for `threshold`. RunJob updates the row at least
32
+ # once per processed record, so updated_at acts as a heartbeat. Call this
33
+ # periodically (cron, recurring job) to recover from worker crashes. Queued
34
+ # runs are included only with an explicit enqueued_threshold longer than
35
+ # normal queue latency. Revoking the token fences out a surviving worker.
36
+ # Returns the number of reaped runs.
37
+ def self.reap_stale!(threshold: 30.minutes, enqueued_threshold: nil)
38
+ stale = where(status: STALE_CANDIDATE_STATUSES).where(updated_at: ...threshold.ago)
39
+ if enqueued_threshold
40
+ stale = stale.or(where(status: "enqueued").where(updated_at: ...enqueued_threshold.ago))
41
+ end
42
+ stale.update_all(
43
+ status: "errored",
44
+ execution_token: nil,
45
+ error_message: "Run marked as stale: no progress before the configured timeout. The worker or dispatch may have failed.",
46
+ completed_at: Time.current,
47
+ updated_at: Time.current
48
+ )
49
+ end
50
+
51
+ # Deletes finished runs older than `older_than`, with their artifacts.
52
+ # Nothing expires these rows on its own, so a long-lived app accumulates
53
+ # every run, backtrace and stored blob forever. Call this periodically the
54
+ # same way as reap_stale!:
55
+ #
56
+ # MaintenanceOnSteroids::Run.prune!(older_than: 90.days)
57
+ #
58
+ # Only terminal runs are eligible -- anything still active or paused is
59
+ # left alone regardless of age. Returns the number of runs deleted.
60
+ def self.prune!(older_than: 90.days, statuses: TERMINAL_STATUSES)
61
+ scope = where(status: Array(statuses) & TERMINAL_STATUSES)
62
+ .where(created_at: ...older_than.ago)
63
+
64
+ # destroy_all rather than delete_all so dependent artifacts go too;
65
+ # batched so pruning a large backlog doesn't build one huge transaction.
66
+ deleted = 0
67
+ scope.in_batches(of: 500) do |batch|
68
+ deleted += batch.destroy_all.size
69
+ end
70
+ deleted
71
+ end
72
+
73
+ def active?
74
+ ACTIVE_STATUSES.include?(status)
75
+ end
76
+
77
+ def stoppable?
78
+ %w[running pausing].include?(status)
79
+ end
80
+
81
+ def pausable?
82
+ status == "running"
83
+ end
84
+
85
+ def resumable?
86
+ RESUMABLE_STATUSES.include?(status)
87
+ end
88
+
89
+ def cancellable?
90
+ ACTIVE_STATUSES.include?(status)
91
+ end
92
+
93
+ def progress_percentage
94
+ return 0 if progress_total.zero?
95
+ [(progress_current.to_f / progress_total * 100).round(1), 100.0].min
96
+ end
97
+
98
+ def duration
99
+ return nil unless started_at
100
+ end_time = completed_at || Time.current
101
+ end_time - started_at
102
+ end
103
+
104
+ def formatted_duration
105
+ return "—" unless duration
106
+ seconds = duration.to_i
107
+ if seconds < 60
108
+ "#{seconds}s"
109
+ elsif seconds < 3600
110
+ "#{seconds / 60}m #{seconds % 60}s"
111
+ else
112
+ "#{seconds / 3600}h #{(seconds % 3600) / 60}m"
113
+ end
114
+ end
115
+
116
+ # Estimated time remaining for the pending records, extrapolated from the
117
+ # current processing rate. Only meaningful while progress is being tracked.
118
+ def estimated_duration
119
+ return nil unless running? && started_at && progress_total.positive? && progress_current.positive?
120
+ # No estimate once we've reached (or overshot) the total -- nothing
121
+ # pending, and progress_current > progress_total would go negative.
122
+ return nil unless progress_current < progress_total
123
+ duration * (progress_total - progress_current) / progress_current
124
+ end
125
+
126
+ def formatted_estimated_duration
127
+ total = estimated_duration
128
+ return nil unless total
129
+
130
+ total = total.round
131
+ days = total / 86_400
132
+ hours = (total % 86_400) / 3600
133
+ mins = (total % 3600) / 60
134
+ secs = total % 60
135
+
136
+ parts = []
137
+ parts << "#{days}d" if days.positive?
138
+ parts << "#{hours}h" if hours.positive? || days.positive?
139
+ parts << "#{mins}m" if mins.positive? || hours.positive? || days.positive?
140
+ parts << "#{secs}s"
141
+ parts.join(" ")
142
+ end
143
+
144
+ def task_instance
145
+ @task_instance ||= begin
146
+ klass = JobRegistry.find(task_class)
147
+ raise ArgumentError, "Unknown maintenance task: #{task_class}" unless klass
148
+ klass.new(self)
149
+ end
150
+ end
151
+
152
+ def enqueue!(job_id: SecureRandom.uuid)
153
+ previous_backtrace = error_backtrace
154
+ job_config = task_instance.class.job_config
155
+ job = RunJob.new(id)
156
+ job.job_id = job_id
157
+ job.enqueue_failure_backtrace = previous_backtrace
158
+ job.queue_name = job_config.queue_name if job_config.queue_name
159
+ job.priority = job_config.priority if job_config.priority
160
+
161
+ with_lock do
162
+ return false unless enqueued? && execution_token.nil? && (active_job_id.nil? || active_job_id == job_id)
163
+ # Publish the attempt identity and reset metadata before a worker can run.
164
+ update!(active_job_id: job_id, completed_at: nil, error_message: nil, error_backtrace: nil)
165
+ end
166
+ unless job.enqueue
167
+ raise EnqueueFailed, job.enqueue_error&.message || "Enqueue callback aborted the job"
168
+ end
169
+ safe_instrument(:enqueued)
170
+ true
171
+ rescue => e
172
+ # A fast worker may already have completed or failed. Never overwrite it,
173
+ # or a newer attempt, when dispatch reports an error.
174
+ self.class.where(id: id, status: "enqueued", execution_token: nil, active_job_id: [nil, job_id]).update_all(
175
+ status: "errored", error_message: "Failed to enqueue: #{e.message}",
176
+ error_backtrace: previous_backtrace, completed_at: Time.current, updated_at: Time.current
177
+ )
178
+ reload
179
+ raise EnqueueFailed, e.message
180
+ end
181
+
182
+ # All worker bookkeeping and buffered output commits are fenced by this
183
+ # token. The lock is short-lived; task side effects happen outside it.
184
+ def with_execution_lock
185
+ with_lock do
186
+ unless worker_token && execution_token == worker_token && STALE_CANDIDATE_STATUSES.include?(status)
187
+ raise ExecutionLost, "Run #{id} no longer belongs to this worker"
188
+ end
189
+ yield
190
+ end
191
+ end
192
+
193
+ # Compare-and-set, like #resume!: a check-then-update would let a pause
194
+ # clicked as the job finishes overwrite "completed" with "pausing", which
195
+ # nothing but reap_stale! would ever clear.
196
+ # Returns true when the transition was performed, false otherwise.
197
+ def pause!
198
+ claimed = self.class.where(id: id, status: "running").update_all(
199
+ status: "pausing",
200
+ updated_at: Time.current
201
+ ) == 1
202
+ reload if claimed
203
+ claimed
204
+ end
205
+
206
+ # Compare-and-set so two concurrent resumes can't both enqueue a job
207
+ # for the same run. Returns true when this call won the transition.
208
+ # Raises EnqueueFailed if the job could not be queued.
209
+ def resume!
210
+ job_id = SecureRandom.uuid
211
+ claimed = self.class.where(id: id, status: RESUMABLE_STATUSES).update_all(
212
+ status: "enqueued",
213
+ active_job_id: job_id,
214
+ execution_token: nil,
215
+ updated_at: Time.current
216
+ ) == 1
217
+ return false unless claimed
218
+
219
+ reload
220
+ return false unless enqueue!(job_id: job_id)
221
+ safe_instrument(:resumed)
222
+ true
223
+ end
224
+
225
+ # Returns true when the transition was performed, false otherwise.
226
+ # NOTE: an already-enqueued job cannot be portably removed from the queue
227
+ # via Active Job. RunJob#perform guards on terminal statuses, so a job
228
+ # that still fires for a cancelled run is a no-op.
229
+ def cancel!
230
+ # Compare-and-set on each group, for the same reason as #pause!.
231
+ if self.class.where(id: id, status: %w[enqueued paused]).update_all(
232
+ status: "cancelled", completed_at: Time.current, updated_at: Time.current
233
+ ) == 1
234
+ reload
235
+ safe_instrument(:cancelled)
236
+ true
237
+ elsif self.class.where(id: id, status: %w[running pausing]).update_all(
238
+ status: "cancelling", updated_at: Time.current
239
+ ) == 1
240
+ reload
241
+ true
242
+ else
243
+ false
244
+ end
245
+ end
246
+
247
+ # Store current user who triggered this run.
248
+ # Accepts either a user object directly, or resolves via the configured resolver.
249
+ def record_user!(user = nil)
250
+ user ||= resolve_current_user
251
+ return unless user
252
+
253
+ self.user_id = user.id.to_s
254
+ self.user_type = user.class.name
255
+ self.user_email = user.email if user.respond_to?(:email)
256
+ end
257
+
258
+ # Formatted user display for the UI.
259
+ # Uses the configured formatter or falls back to a sensible default.
260
+ def user_display
261
+ return nil if user_id.blank?
262
+
263
+ if MaintenanceOnSteroids.user_display_formatter
264
+ MaintenanceOnSteroids.user_display_formatter.call(self)
265
+ elsif user_email.present?
266
+ user_email
267
+ else
268
+ "#{user_type}##{user_id}"
269
+ end
270
+ rescue => e
271
+ Rails.logger.debug "[MaintenanceOnSteroids] user_display_formatter error: #{e.message}"
272
+ "#{user_type}##{user_id}"
273
+ end
274
+
275
+ def task_title
276
+ klass = JobRegistry.find(task_class)
277
+ klass&.task_title || task_class
278
+ rescue
279
+ task_class
280
+ end
281
+
282
+ def task_exists?
283
+ JobRegistry.find(task_class).present?
284
+ rescue
285
+ false
286
+ end
287
+
288
+ def output_artifacts
289
+ artifacts.where(kind: "output")
290
+ end
291
+
292
+ def input_artifacts
293
+ artifacts.where(kind: "input")
294
+ end
295
+
296
+ private
297
+
298
+ def safe_instrument(event, extra = {})
299
+ Instrumentation.safe_instrument(event, self, extra)
300
+ end
301
+
302
+ def resolve_current_user
303
+ resolver = MaintenanceOnSteroids.current_user_resolver
304
+ resolver&.call
305
+ rescue => e
306
+ Rails.logger.debug "[MaintenanceOnSteroids] Could not resolve current user: #{e.message}"
307
+ nil
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,48 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Maintenance on Steroids</title>
7
+ <link rel="icon" href="/favicon.ico">
8
+ <%# Suppress the browser's automatic /favicon.ico request when the host app
9
+ has no favicon -- harmless if the file is absent. %>
10
+ <%= render "maintenance_on_steroids/shared/styles" %>
11
+ <script>
12
+ (function() {
13
+ var saved = localStorage.getItem('mos-theme') || 'dark';
14
+ document.documentElement.setAttribute('data-theme', saved);
15
+ })();
16
+ </script>
17
+ </head>
18
+ <body>
19
+ <div class="topbar">
20
+ <div class="topbar-inner">
21
+ <a href="<%= root_path %>" class="topbar-brand">
22
+ <span class="brand-main">Maintenance</span> <span class="brand-highlight">on Steroids</span>
23
+ </a>
24
+ <div class="topbar-nav">
25
+ <a href="<%= root_path %>" class="<%= "active" if request.path == root_path %>">Dashboard</a>
26
+ <a href="<%= jobs_path %>" class="<%= "active" if request.path.start_with?(jobs_path) %>">Tasks</a>
27
+ <div class="topbar-sep"></div>
28
+ <button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme" id="theme-btn">
29
+ <span id="theme-icon"></span>
30
+ </button>
31
+ </div>
32
+ </div>
33
+ </div>
34
+
35
+ <div class="container">
36
+ <% if notice %>
37
+ <div class="flash flash-notice"><%= notice %></div>
38
+ <% end %>
39
+ <% if alert %>
40
+ <div class="flash flash-alert"><%= alert %></div>
41
+ <% end %>
42
+
43
+ <%= yield %>
44
+ </div>
45
+
46
+ <%= render "maintenance_on_steroids/shared/javascript" %>
47
+ </body>
48
+ </html>