solid_log 0.3.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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +300 -0
  4. data/Rakefile +12 -0
  5. data/app/assets/javascripts/application.js +6 -0
  6. data/app/assets/javascripts/solid_log/checkbox_dropdown.js +171 -0
  7. data/app/assets/javascripts/solid_log/filter_state.js +138 -0
  8. data/app/assets/javascripts/solid_log/jump_to_live.js +119 -0
  9. data/app/assets/javascripts/solid_log/live_tail.js +476 -0
  10. data/app/assets/javascripts/solid_log/live_tail.js.bak +270 -0
  11. data/app/assets/javascripts/solid_log/log_filters.js +37 -0
  12. data/app/assets/javascripts/solid_log/stream_scroll.js +195 -0
  13. data/app/assets/javascripts/solid_log/timeline_histogram.js +162 -0
  14. data/app/assets/javascripts/solid_log/toast.js +50 -0
  15. data/app/assets/stylesheets/solid_log/application.css +1329 -0
  16. data/app/assets/stylesheets/solid_log/components.css +1506 -0
  17. data/app/assets/stylesheets/solid_log/mission_control.css +398 -0
  18. data/app/channels/solid_log/ui/application_cable/channel.rb +8 -0
  19. data/app/channels/solid_log/ui/application_cable/connection.rb +10 -0
  20. data/app/channels/solid_log/ui/log_stream_channel.rb +135 -0
  21. data/app/controllers/solid_log/ui/base_controller.rb +122 -0
  22. data/app/controllers/solid_log/ui/dashboard_controller.rb +32 -0
  23. data/app/controllers/solid_log/ui/entries_controller.rb +34 -0
  24. data/app/controllers/solid_log/ui/fields_controller.rb +57 -0
  25. data/app/controllers/solid_log/ui/streams_controller.rb +204 -0
  26. data/app/controllers/solid_log/ui/timelines_controller.rb +29 -0
  27. data/app/controllers/solid_log/ui/tokens_controller.rb +46 -0
  28. data/app/helpers/solid_log/ui/application_helper.rb +99 -0
  29. data/app/helpers/solid_log/ui/dashboard_helper.rb +46 -0
  30. data/app/helpers/solid_log/ui/entries_helper.rb +16 -0
  31. data/app/helpers/solid_log/ui/timeline_helper.rb +39 -0
  32. data/app/services/solid_log/ui/live_tail_broadcaster.rb +81 -0
  33. data/app/views/layouts/solid_log/ui/application.html.erb +53 -0
  34. data/app/views/solid_log/ui/dashboard/index.html.erb +178 -0
  35. data/app/views/solid_log/ui/entries/show.html.erb +132 -0
  36. data/app/views/solid_log/ui/fields/index.html.erb +133 -0
  37. data/app/views/solid_log/ui/shared/_checkbox_dropdown.html.erb +64 -0
  38. data/app/views/solid_log/ui/shared/_multiselect_filter.html.erb +37 -0
  39. data/app/views/solid_log/ui/shared/_toast.html.erb +7 -0
  40. data/app/views/solid_log/ui/shared/_toast_message.html.erb +30 -0
  41. data/app/views/solid_log/ui/streams/_filter_form.html.erb +207 -0
  42. data/app/views/solid_log/ui/streams/_footer.html.erb +37 -0
  43. data/app/views/solid_log/ui/streams/_log_entries.html.erb +5 -0
  44. data/app/views/solid_log/ui/streams/_log_row.html.erb +5 -0
  45. data/app/views/solid_log/ui/streams/_log_row_compact.html.erb +37 -0
  46. data/app/views/solid_log/ui/streams/_log_row_expanded.html.erb +67 -0
  47. data/app/views/solid_log/ui/streams/_log_stream_content.html.erb +8 -0
  48. data/app/views/solid_log/ui/streams/_timeline.html.erb +68 -0
  49. data/app/views/solid_log/ui/streams/index.html.erb +22 -0
  50. data/app/views/solid_log/ui/timelines/show_job.html.erb +78 -0
  51. data/app/views/solid_log/ui/timelines/show_request.html.erb +88 -0
  52. data/app/views/solid_log/ui/tokens/index.html.erb +95 -0
  53. data/app/views/solid_log/ui/tokens/new.html.erb +47 -0
  54. data/config/importmap.rb +15 -0
  55. data/config/routes.rb +27 -0
  56. data/lib/solid_log/ui/api_client.rb +117 -0
  57. data/lib/solid_log/ui/configuration.rb +99 -0
  58. data/lib/solid_log/ui/data_source.rb +146 -0
  59. data/lib/solid_log/ui/engine.rb +76 -0
  60. data/lib/solid_log/ui/version.rb +5 -0
  61. data/lib/solid_log/ui.rb +27 -0
  62. data/lib/solid_log.rb +3 -0
  63. metadata +291 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 19af10eea6c1f705fbe0630cff196468734bce893b969594af89ea2b01cec50a
4
+ data.tar.gz: 2daba8c39781e9871a079d8749e5eeccf71bb71ee5416d19a1a1eb9a73e6dad5
5
+ SHA512:
6
+ metadata.gz: d898137090d96c98c8e41a173dcc0a61ee3814937846ca46121ddbee05692215498d6c97b7db9e01c6377c27da15adbb7cd4cd8b0f25d90d302823f17cac2520
7
+ data.tar.gz: fbc11d4c1edd26f97c0589535a4d493d987da20e3b686a875330c85d0b0cc11af87f3521eea6852ab95441c2cd89c49be2144c3ce91eb764707a2165ab05e7cc
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Dan Loman
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,300 @@
1
+ # SolidLog
2
+
3
+ All-in-one log aggregation gem for Rails applications. Includes core models, database adapters, and Mission Control-style web interface.
4
+
5
+ ## Overview
6
+
7
+ `solid_log` is the main gem that provides everything you need:
8
+
9
+ - **Core functionality** (from solid_log-core - auto-required):
10
+ - Database models and adapters (SQLite, PostgreSQL, MySQL)
11
+ - Parser for structured JSON logs
12
+ - Background jobs (ParseJob, RetentionJob, etc.)
13
+ - DirectLogger for high-performance logging
14
+ - **Mission Control-style UI**: Browse, filter, and search logs
15
+ - **Dual-mode support**:
16
+ - **Direct DB**: Fast access when UI and service share database
17
+ - **HTTP API**: Remote access when service runs separately
18
+ - **Overridable authentication**: Easy integration with your auth system
19
+ - **Real-time updates**: Live tail support (WebSocket or polling)
20
+ - **Full-text search**: Powered by database-native FTS
21
+ - **Request/job correlation**: Timeline views for related logs
22
+
23
+ ## Installation
24
+
25
+ ```ruby
26
+ gem 'solid_log'
27
+
28
+ # Also install database adapter if using direct_db mode
29
+ gem 'sqlite3', '>= 2.1' # or pg, or mysql2
30
+ ```
31
+
32
+ ## Configuration
33
+
34
+ Create `config/initializers/solid_log_ui.rb`:
35
+
36
+ ### Direct DB Mode (Default)
37
+
38
+ ```ruby
39
+ SolidLog::UI.configure do |config|
40
+ config.mode = :direct_db
41
+ config.authentication_method = :custom # Override BaseController
42
+ config.stream_view_style = :compact
43
+ config.per_page = 100
44
+ end
45
+ ```
46
+
47
+ ### HTTP API Mode
48
+
49
+ ```ruby
50
+ SolidLog::UI.configure do |config|
51
+ config.mode = :http_api
52
+ config.service_url = ENV['SOLIDLOG_SERVICE_URL']
53
+ config.service_token = ENV['SOLIDLOG_SERVICE_TOKEN']
54
+ config.authentication_method = :custom
55
+ end
56
+ ```
57
+
58
+ ## Mount in Routes
59
+
60
+ ```ruby
61
+ # config/routes.rb
62
+ Rails.application.routes.draw do
63
+ mount SolidLog::UI::Engine => "/admin/logs"
64
+ end
65
+ ```
66
+
67
+ Access at: `http://yourapp.com/admin/logs`
68
+
69
+ ## Authentication
70
+
71
+ The `BaseController` is designed to be easily overridden in your host application.
72
+
73
+ ### Option 1: Reopen the Class (Recommended)
74
+
75
+ Create `config/initializers/solid_log_ui_auth.rb`:
76
+
77
+ ```ruby
78
+ # Use your existing authentication system
79
+ SolidLog::UI::BaseController.class_eval do
80
+ before_action :require_admin
81
+
82
+ private
83
+
84
+ def require_admin
85
+ redirect_to root_path unless current_user&.admin?
86
+ end
87
+
88
+ # Override current_user to use your app's authentication
89
+ def current_user
90
+ @current_user ||= User.find_by(id: session[:user_id])
91
+ end
92
+ end
93
+ ```
94
+
95
+ ### Option 2: HTTP Basic Auth
96
+
97
+ ```ruby
98
+ # config/initializers/solid_log_ui.rb
99
+ SolidLog::UI.configure do |config|
100
+ config.authentication_method = :basic
101
+ end
102
+
103
+ # Store credentials in Rails credentials
104
+ # rails credentials:edit
105
+ solidlog:
106
+ username: admin
107
+ password: secret_password
108
+ ```
109
+
110
+ Or override the auth method:
111
+
112
+ ```ruby
113
+ SolidLog::UI::BaseController.class_eval do
114
+ protected
115
+
116
+ def authenticate_with_basic_auth(username, password)
117
+ username == ENV['ADMIN_USER'] && password == ENV['ADMIN_PASSWORD']
118
+ end
119
+ end
120
+ ```
121
+
122
+ ### Option 3: Devise Integration
123
+
124
+ ```ruby
125
+ SolidLog::UI::BaseController.class_eval do
126
+ before_action :authenticate_admin_user!
127
+
128
+ private
129
+
130
+ def authenticate_admin_user!
131
+ authenticate_user!
132
+ redirect_to root_path unless current_user.admin?
133
+ end
134
+
135
+ # Devise provides current_user automatically
136
+ end
137
+ ```
138
+
139
+ ### Option 4: Custom Middleware
140
+
141
+ ```ruby
142
+ SolidLog::UI::BaseController.class_eval do
143
+ before_action :check_api_key
144
+
145
+ private
146
+
147
+ def check_api_key
148
+ api_key = request.headers['X-Admin-API-Key']
149
+ head :unauthorized unless api_key == ENV['ADMIN_API_KEY']
150
+ end
151
+ end
152
+ ```
153
+
154
+ ### Option 5: IP Whitelist
155
+
156
+ ```ruby
157
+ SolidLog::UI::BaseController.class_eval do
158
+ before_action :check_ip_whitelist
159
+
160
+ private
161
+
162
+ def check_ip_whitelist
163
+ allowed_ips = ENV['ALLOWED_IPS'].to_s.split(',')
164
+ unless allowed_ips.include?(request.remote_ip)
165
+ render plain: "Access denied", status: :forbidden
166
+ end
167
+ end
168
+ end
169
+ ```
170
+
171
+ ## Deployment Modes
172
+
173
+ ### Mode 1: Direct DB (Fast, Same Host)
174
+
175
+ **Use when**: UI and service run on same host with shared database
176
+
177
+ ```ruby
178
+ # config/initializers/solid_log_ui.rb
179
+ SolidLog::UI.configure do |config|
180
+ config.mode = :direct_db
181
+ end
182
+
183
+ # config/database.yml
184
+ production:
185
+ primary:
186
+ adapter: sqlite3
187
+ database: storage/production.sqlite3
188
+ log:
189
+ adapter: sqlite3
190
+ database: storage/production_log.sqlite3 # Shared with service
191
+ migrations_paths: db/log_migrate
192
+ ```
193
+
194
+ **Benefits:**
195
+ - ✅ Fastest (direct database queries)
196
+ - ✅ No HTTP overhead
197
+ - ✅ Works with shared volume in Kamal
198
+
199
+ ### Mode 2: HTTP API (Flexible, Remote)
200
+
201
+ **Use when**: Service runs separately from main app
202
+
203
+ ```ruby
204
+ # config/initializers/solid_log_ui.rb
205
+ SolidLog::UI.configure do |config|
206
+ config.mode = :http_api
207
+ config.service_url = 'http://solidlog-service:3001'
208
+ config.service_token = ENV['SOLIDLOG_TOKEN']
209
+ end
210
+ ```
211
+
212
+ **Benefits:**
213
+ - ✅ Service can run independently
214
+ - ✅ UI can be in separate app/server
215
+ - ✅ Works across network boundaries
216
+
217
+ ## Features
218
+
219
+ ### Streams View
220
+ - Filter by level, app, env, controller, action, path, method, status
221
+ - Full-text search
222
+ - Compact or expanded view modes
223
+ - Live tail (auto-refresh)
224
+
225
+ ### Entry Details
226
+ - Full log entry with all fields
227
+ - JSON-formatted extra fields
228
+ - Copy to clipboard
229
+ - Related entries (request/job correlation)
230
+
231
+ ### Timelines
232
+ - Request timeline: All logs for a request_id
233
+ - Job timeline: All logs for a job_id
234
+ - Duration visualization
235
+ - Level distribution
236
+
237
+ ### Dashboard
238
+ - Recent error rate
239
+ - Ingestion metrics
240
+ - Parse backlog status
241
+ - Database size
242
+ - Health indicators
243
+
244
+ ## Helper Methods
245
+
246
+ Available in all UI views:
247
+
248
+ ```erb
249
+ <% if current_user %>
250
+ Welcome, <%= current_user.email %>
251
+ <% end %>
252
+
253
+ <%= level_badge(entry.level) %>
254
+ <%= duration_badge(entry.duration) %>
255
+ <%= status_code_badge(entry.status_code) %>
256
+ ```
257
+
258
+ ## Customizing Views
259
+
260
+ Override views by creating matching files in your app:
261
+
262
+ ```
263
+ app/views/solid_log/ui/
264
+ ├── streams/
265
+ │ └── index.html.erb # Override streams view
266
+ ├── entries/
267
+ │ └── show.html.erb # Override entry detail view
268
+ └── layouts/
269
+ └── solid_log/
270
+ └── ui/
271
+ └── application.html.erb # Override layout
272
+ ```
273
+
274
+ ## Customizing Styles
275
+
276
+ Add custom CSS in your application:
277
+
278
+ ```css
279
+ /* app/assets/stylesheets/solid_log_custom.css */
280
+ .solid-log-stream-entry {
281
+ border-left: 4px solid #your-brand-color;
282
+ }
283
+ ```
284
+
285
+ Then import in your application.css:
286
+
287
+ ```css
288
+ @import "solid_log_custom";
289
+ ```
290
+
291
+ ## Development
292
+
293
+ ```bash
294
+ cd solid_log
295
+ bundle install
296
+ ```
297
+
298
+ ## License
299
+
300
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ t.verbose = true
9
+ t.warning = false
10
+ end
11
+
12
+ task default: :test
@@ -0,0 +1,6 @@
1
+ // Entry point for the build script in your package.json
2
+ import "@hotwired/turbo-rails"
3
+ import { createConsumer } from "@rails/actioncable"
4
+
5
+ // Make createConsumer available globally for live tail
6
+ window.createConsumer = createConsumer
@@ -0,0 +1,171 @@
1
+ // Checkbox Dropdown functionality for multi-select filters
2
+ (function() {
3
+ let globalListenersAdded = false;
4
+
5
+ function closeDropdown(dropdown) {
6
+ const toggle = dropdown.querySelector('[data-action*="toggle"]');
7
+ const menu = dropdown.querySelector('[data-checkbox-dropdown-target="menu"]');
8
+ if (toggle && menu) {
9
+ toggle.setAttribute('aria-expanded', 'false');
10
+ menu.style.display = 'none';
11
+ }
12
+ }
13
+
14
+ function initializeCheckboxDropdowns() {
15
+ const dropdowns = document.querySelectorAll('[data-controller="checkbox-dropdown"]');
16
+
17
+ dropdowns.forEach(dropdown => {
18
+ // Skip if already initialized
19
+ if (dropdown.dataset.initialized === 'true') return;
20
+ dropdown.dataset.initialized = 'true';
21
+
22
+ const toggle = dropdown.querySelector('[data-action*="toggle"]');
23
+ const menu = dropdown.querySelector('[data-checkbox-dropdown-target="menu"]');
24
+ const search = dropdown.querySelector('[data-checkbox-dropdown-target="search"]');
25
+ const options = dropdown.querySelectorAll('[data-checkbox-dropdown-target="option"]');
26
+ const checkboxes = dropdown.querySelectorAll('input[type="checkbox"]');
27
+ const badge = dropdown.querySelector('.badge-small');
28
+ const closeBtn = dropdown.querySelector('.popover-close');
29
+ const doneBtn = dropdown.querySelector('.popover-footer button');
30
+ const dropdownLabel = toggle?.querySelector('.dropdown-label');
31
+
32
+ if (!toggle || !menu) return;
33
+
34
+ // Toggle dropdown
35
+ toggle.addEventListener('click', function(e) {
36
+ e.preventDefault();
37
+ e.stopPropagation();
38
+ const isExpanded = toggle.getAttribute('aria-expanded') === 'true';
39
+
40
+ // Close all other dropdowns
41
+ document.querySelectorAll('[data-controller="checkbox-dropdown"]').forEach(other => {
42
+ if (other !== dropdown) {
43
+ closeDropdown(other);
44
+ }
45
+ });
46
+
47
+ // Toggle this dropdown
48
+ toggle.setAttribute('aria-expanded', !isExpanded);
49
+ menu.style.display = isExpanded ? 'none' : 'flex';
50
+
51
+ // Focus search if opening
52
+ if (!isExpanded && search) {
53
+ setTimeout(() => search.focus(), 100);
54
+ }
55
+ });
56
+
57
+ // Close button (X in header)
58
+ if (closeBtn) {
59
+ closeBtn.addEventListener('click', function(e) {
60
+ e.preventDefault();
61
+ e.stopPropagation();
62
+ closeDropdown(dropdown);
63
+ });
64
+ }
65
+
66
+ // Done button (in footer)
67
+ if (doneBtn) {
68
+ doneBtn.addEventListener('click', function(e) {
69
+ e.preventDefault();
70
+ e.stopPropagation();
71
+ closeDropdown(dropdown);
72
+ });
73
+ }
74
+
75
+ // Filter options
76
+ if (search) {
77
+ search.addEventListener('input', function() {
78
+ const filter = this.value.toLowerCase();
79
+ options.forEach(option => {
80
+ const value = option.getAttribute('data-value') || '';
81
+ if (value.includes(filter)) {
82
+ option.style.display = '';
83
+ } else {
84
+ option.style.display = 'none';
85
+ }
86
+ });
87
+ });
88
+ }
89
+
90
+ // Update count badge and preview
91
+ function updateCountAndPreview() {
92
+ const checkedBoxes = Array.from(checkboxes).filter(cb => cb.checked);
93
+ const count = checkedBoxes.length;
94
+
95
+ // Update badge
96
+ if (badge) {
97
+ badge.textContent = count;
98
+ badge.style.display = count > 0 ? '' : 'none';
99
+ }
100
+
101
+ // Update preview label in toggle button
102
+ if (dropdownLabel) {
103
+ if (count > 0) {
104
+ const selectedValues = checkedBoxes.map(cb => cb.value);
105
+ const previewText = selectedValues.join(', ');
106
+ dropdownLabel.textContent = previewText.length > 50 ? previewText.substring(0, 50) + '...' : previewText;
107
+ dropdownLabel.classList.remove('dropdown-label-placeholder');
108
+ } else {
109
+ // Get original label from popover header
110
+ const popoverHeader = menu.querySelector('.popover-header h4');
111
+ if (popoverHeader) {
112
+ dropdownLabel.textContent = popoverHeader.textContent;
113
+ dropdownLabel.classList.add('dropdown-label-placeholder');
114
+ }
115
+ }
116
+ }
117
+ }
118
+
119
+ checkboxes.forEach(checkbox => {
120
+ checkbox.addEventListener('change', updateCountAndPreview);
121
+ });
122
+
123
+ // Initialize count and preview on next frame to ensure DOM is ready
124
+ requestAnimationFrame(() => {
125
+ updateCountAndPreview();
126
+ });
127
+ });
128
+
129
+ // Add global event listeners only once
130
+ if (!globalListenersAdded) {
131
+ globalListenersAdded = true;
132
+
133
+ // Close dropdowns when clicking outside or pressing escape
134
+ document.addEventListener('click', function(e) {
135
+ if (!e.target.closest('[data-controller="checkbox-dropdown"]')) {
136
+ document.querySelectorAll('[data-controller="checkbox-dropdown"]').forEach(dropdown => {
137
+ closeDropdown(dropdown);
138
+ });
139
+ }
140
+ });
141
+
142
+ document.addEventListener('keydown', function(e) {
143
+ if (e.key === 'Escape') {
144
+ document.querySelectorAll('[data-controller="checkbox-dropdown"]').forEach(dropdown => {
145
+ closeDropdown(dropdown);
146
+ });
147
+ }
148
+ });
149
+
150
+ // Close dropdowns when filter form content scrolls
151
+ const filterFormContent = document.querySelector('.filter-form-content');
152
+ if (filterFormContent) {
153
+ filterFormContent.addEventListener('scroll', function() {
154
+ document.querySelectorAll('[data-controller="checkbox-dropdown"]').forEach(dropdown => {
155
+ closeDropdown(dropdown);
156
+ });
157
+ });
158
+ }
159
+ }
160
+ }
161
+
162
+ // Initialize on page load
163
+ if (document.readyState === 'loading') {
164
+ document.addEventListener('DOMContentLoaded', initializeCheckboxDropdowns);
165
+ } else {
166
+ initializeCheckboxDropdowns();
167
+ }
168
+
169
+ // Re-initialize on Turbo load (if using Turbo)
170
+ document.addEventListener('turbo:load', initializeCheckboxDropdowns);
171
+ })();
@@ -0,0 +1,138 @@
1
+ // Filter form state management - disable/enable buttons based on changes
2
+ (function() {
3
+ function initializeFilterState() {
4
+ const filterForm = document.querySelector('.filter-form form');
5
+ if (!filterForm) return;
6
+
7
+ const applyButton = filterForm.querySelector('[type="submit"]');
8
+ const clearButton = filterForm.querySelector('a[href*="streams"]');
9
+
10
+ if (!applyButton) return;
11
+
12
+ // Store initial form state
13
+ const initialFormData = new FormData(filterForm);
14
+ const initialState = formDataToObject(initialFormData);
15
+
16
+ // Check if any filters are currently active
17
+ function hasActiveFilters() {
18
+ const currentFormData = new FormData(filterForm);
19
+ const currentState = formDataToObject(currentFormData);
20
+
21
+ // Check if any filter has a value
22
+ for (let key in currentState) {
23
+ if (currentState[key] && currentState[key].length > 0) {
24
+ // Ignore empty strings and empty arrays
25
+ if (Array.isArray(currentState[key])) {
26
+ if (currentState[key].some(v => v !== '')) return true;
27
+ } else if (currentState[key] !== '') {
28
+ return true;
29
+ }
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+
35
+ // Check if form has changed from initial state
36
+ function hasFormChanged() {
37
+ const currentFormData = new FormData(filterForm);
38
+ const currentState = formDataToObject(currentFormData);
39
+
40
+ return !areStatesEqual(initialState, currentState);
41
+ }
42
+
43
+ // Update button states
44
+ function updateButtonStates() {
45
+ const hasChanges = hasFormChanged();
46
+ const hasFilters = hasActiveFilters();
47
+
48
+ // Disable Apply button if no changes
49
+ if (applyButton) {
50
+ applyButton.disabled = !hasChanges;
51
+ if (hasChanges) {
52
+ applyButton.classList.remove('btn-disabled');
53
+ } else {
54
+ applyButton.classList.add('btn-disabled');
55
+ }
56
+ }
57
+
58
+ // Disable Clear button if no active filters
59
+ if (clearButton) {
60
+ if (hasFilters) {
61
+ clearButton.classList.remove('btn-disabled');
62
+ clearButton.style.pointerEvents = '';
63
+ } else {
64
+ clearButton.classList.add('btn-disabled');
65
+ clearButton.style.pointerEvents = 'none';
66
+ }
67
+ }
68
+ }
69
+
70
+ // Listen to all form input changes
71
+ filterForm.addEventListener('input', updateButtonStates);
72
+ filterForm.addEventListener('change', updateButtonStates);
73
+
74
+ // Initial state
75
+ updateButtonStates();
76
+ }
77
+
78
+ // Helper: Convert FormData to plain object
79
+ function formDataToObject(formData) {
80
+ const obj = {};
81
+ for (let [key, value] of formData.entries()) {
82
+ if (obj[key]) {
83
+ // Multiple values for same key (e.g., checkboxes)
84
+ if (Array.isArray(obj[key])) {
85
+ obj[key].push(value);
86
+ } else {
87
+ obj[key] = [obj[key], value];
88
+ }
89
+ } else {
90
+ obj[key] = value;
91
+ }
92
+ }
93
+ return obj;
94
+ }
95
+
96
+ // Helper: Deep compare two state objects
97
+ function areStatesEqual(state1, state2) {
98
+ const keys1 = Object.keys(state1);
99
+ const keys2 = Object.keys(state2);
100
+
101
+ // Check if they have the same number of keys
102
+ if (keys1.length !== keys2.length) return false;
103
+
104
+ // Check each key
105
+ for (let key of keys1) {
106
+ const val1 = state1[key];
107
+ const val2 = state2[key];
108
+
109
+ // Both arrays
110
+ if (Array.isArray(val1) && Array.isArray(val2)) {
111
+ if (val1.length !== val2.length) return false;
112
+ for (let i = 0; i < val1.length; i++) {
113
+ if (val1[i] !== val2[i]) return false;
114
+ }
115
+ }
116
+ // One is array, other isn't
117
+ else if (Array.isArray(val1) || Array.isArray(val2)) {
118
+ return false;
119
+ }
120
+ // Both are simple values
121
+ else if (val1 !== val2) {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ return true;
127
+ }
128
+
129
+ // Initialize on page load
130
+ if (document.readyState === 'loading') {
131
+ document.addEventListener('DOMContentLoaded', initializeFilterState);
132
+ } else {
133
+ initializeFilterState();
134
+ }
135
+
136
+ // Re-initialize on Turbo load (if using Turbo)
137
+ document.addEventListener('turbo:load', initializeFilterState);
138
+ })();