solid_log-ui 0.1.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 +295 -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 +132 -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-ui.rb +2 -0
  63. metadata +290 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ead8787f6f3ddd8c16b1e0cea395ecc05002be6fc3c8307931a2e89bdbdcbc44
4
+ data.tar.gz: b5028b8e5f785d2edb1678f76c0db6ab718d0c5e1a0e272413d4bcda55a08a51
5
+ SHA512:
6
+ metadata.gz: a3e0f3a3f9a9636b312f12558ca9fc9f001d3ce5738655706c4e9ad73f453c0eb116782aef2c39e4d519a5052ac86b7121fd10e955e7060d90cf9961a0c53f82
7
+ data.tar.gz: 7290decb302c621129be634a02132e7c3401e9b6bd4a1a1b0bff204390c78ff8dbd8e8ee710bdfd1e45b9cb6c6cc99a7c2229301d1ec41ae47885113b55aa898
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,295 @@
1
+ ##SolidLog::UI
2
+
3
+ Mission Control-style web interface for viewing SolidLog entries. Supports both direct database access and HTTP API mode.
4
+
5
+ ## Overview
6
+
7
+ `solid_log-ui` provides:
8
+
9
+ - **Mission Control-style UI**: Browse, filter, and search logs
10
+ - **Dual-mode support**:
11
+ - **Direct DB**: Fast access when UI and service share database
12
+ - **HTTP API**: Remote access when service runs separately
13
+ - **Overridable authentication**: Easy integration with your auth system
14
+ - **Real-time updates**: Live tail support (WebSocket or polling)
15
+ - **Full-text search**: Powered by database-native FTS
16
+ - **Request/job correlation**: Timeline views for related logs
17
+
18
+ ## Installation
19
+
20
+ ```ruby
21
+ gem 'solid_log-ui'
22
+
23
+ # Also install database adapter if using direct_db mode
24
+ gem 'sqlite3', '>= 2.1' # or pg, or mysql2
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ Create `config/initializers/solid_log_ui.rb`:
30
+
31
+ ### Direct DB Mode (Default)
32
+
33
+ ```ruby
34
+ SolidLog::UI.configure do |config|
35
+ config.mode = :direct_db
36
+ config.authentication_method = :custom # Override BaseController
37
+ config.stream_view_style = :compact
38
+ config.per_page = 100
39
+ end
40
+ ```
41
+
42
+ ### HTTP API Mode
43
+
44
+ ```ruby
45
+ SolidLog::UI.configure do |config|
46
+ config.mode = :http_api
47
+ config.service_url = ENV['SOLIDLOG_SERVICE_URL']
48
+ config.service_token = ENV['SOLIDLOG_SERVICE_TOKEN']
49
+ config.authentication_method = :custom
50
+ end
51
+ ```
52
+
53
+ ## Mount in Routes
54
+
55
+ ```ruby
56
+ # config/routes.rb
57
+ Rails.application.routes.draw do
58
+ mount SolidLog::UI::Engine => "/admin/logs"
59
+ end
60
+ ```
61
+
62
+ Access at: `http://yourapp.com/admin/logs`
63
+
64
+ ## Authentication
65
+
66
+ The `BaseController` is designed to be easily overridden in your host application.
67
+
68
+ ### Option 1: Reopen the Class (Recommended)
69
+
70
+ Create `config/initializers/solid_log_ui_auth.rb`:
71
+
72
+ ```ruby
73
+ # Use your existing authentication system
74
+ SolidLog::UI::BaseController.class_eval do
75
+ before_action :require_admin
76
+
77
+ private
78
+
79
+ def require_admin
80
+ redirect_to root_path unless current_user&.admin?
81
+ end
82
+
83
+ # Override current_user to use your app's authentication
84
+ def current_user
85
+ @current_user ||= User.find_by(id: session[:user_id])
86
+ end
87
+ end
88
+ ```
89
+
90
+ ### Option 2: HTTP Basic Auth
91
+
92
+ ```ruby
93
+ # config/initializers/solid_log_ui.rb
94
+ SolidLog::UI.configure do |config|
95
+ config.authentication_method = :basic
96
+ end
97
+
98
+ # Store credentials in Rails credentials
99
+ # rails credentials:edit
100
+ solidlog:
101
+ username: admin
102
+ password: secret_password
103
+ ```
104
+
105
+ Or override the auth method:
106
+
107
+ ```ruby
108
+ SolidLog::UI::BaseController.class_eval do
109
+ protected
110
+
111
+ def authenticate_with_basic_auth(username, password)
112
+ username == ENV['ADMIN_USER'] && password == ENV['ADMIN_PASSWORD']
113
+ end
114
+ end
115
+ ```
116
+
117
+ ### Option 3: Devise Integration
118
+
119
+ ```ruby
120
+ SolidLog::UI::BaseController.class_eval do
121
+ before_action :authenticate_admin_user!
122
+
123
+ private
124
+
125
+ def authenticate_admin_user!
126
+ authenticate_user!
127
+ redirect_to root_path unless current_user.admin?
128
+ end
129
+
130
+ # Devise provides current_user automatically
131
+ end
132
+ ```
133
+
134
+ ### Option 4: Custom Middleware
135
+
136
+ ```ruby
137
+ SolidLog::UI::BaseController.class_eval do
138
+ before_action :check_api_key
139
+
140
+ private
141
+
142
+ def check_api_key
143
+ api_key = request.headers['X-Admin-API-Key']
144
+ head :unauthorized unless api_key == ENV['ADMIN_API_KEY']
145
+ end
146
+ end
147
+ ```
148
+
149
+ ### Option 5: IP Whitelist
150
+
151
+ ```ruby
152
+ SolidLog::UI::BaseController.class_eval do
153
+ before_action :check_ip_whitelist
154
+
155
+ private
156
+
157
+ def check_ip_whitelist
158
+ allowed_ips = ENV['ALLOWED_IPS'].to_s.split(',')
159
+ unless allowed_ips.include?(request.remote_ip)
160
+ render plain: "Access denied", status: :forbidden
161
+ end
162
+ end
163
+ end
164
+ ```
165
+
166
+ ## Deployment Modes
167
+
168
+ ### Mode 1: Direct DB (Fast, Same Host)
169
+
170
+ **Use when**: UI and service run on same host with shared database
171
+
172
+ ```ruby
173
+ # config/initializers/solid_log_ui.rb
174
+ SolidLog::UI.configure do |config|
175
+ config.mode = :direct_db
176
+ end
177
+
178
+ # config/database.yml
179
+ production:
180
+ primary:
181
+ adapter: sqlite3
182
+ database: storage/production.sqlite3
183
+ log:
184
+ adapter: sqlite3
185
+ database: storage/production_log.sqlite3 # Shared with service
186
+ migrations_paths: db/log_migrate
187
+ ```
188
+
189
+ **Benefits:**
190
+ - ✅ Fastest (direct database queries)
191
+ - ✅ No HTTP overhead
192
+ - ✅ Works with shared volume in Kamal
193
+
194
+ ### Mode 2: HTTP API (Flexible, Remote)
195
+
196
+ **Use when**: Service runs separately from main app
197
+
198
+ ```ruby
199
+ # config/initializers/solid_log_ui.rb
200
+ SolidLog::UI.configure do |config|
201
+ config.mode = :http_api
202
+ config.service_url = 'http://solidlog-service:3001'
203
+ config.service_token = ENV['SOLIDLOG_TOKEN']
204
+ end
205
+ ```
206
+
207
+ **Benefits:**
208
+ - ✅ Service can run independently
209
+ - ✅ UI can be in separate app/server
210
+ - ✅ Works across network boundaries
211
+
212
+ ## Features
213
+
214
+ ### Streams View
215
+ - Filter by level, app, env, controller, action, path, method, status
216
+ - Full-text search
217
+ - Compact or expanded view modes
218
+ - Live tail (auto-refresh)
219
+
220
+ ### Entry Details
221
+ - Full log entry with all fields
222
+ - JSON-formatted extra fields
223
+ - Copy to clipboard
224
+ - Related entries (request/job correlation)
225
+
226
+ ### Timelines
227
+ - Request timeline: All logs for a request_id
228
+ - Job timeline: All logs for a job_id
229
+ - Duration visualization
230
+ - Level distribution
231
+
232
+ ### Dashboard
233
+ - Recent error rate
234
+ - Ingestion metrics
235
+ - Parse backlog status
236
+ - Database size
237
+ - Health indicators
238
+
239
+ ## Helper Methods
240
+
241
+ Available in all UI views:
242
+
243
+ ```erb
244
+ <% if current_user %>
245
+ Welcome, <%= current_user.email %>
246
+ <% end %>
247
+
248
+ <%= level_badge(entry.level) %>
249
+ <%= duration_badge(entry.duration) %>
250
+ <%= status_code_badge(entry.status_code) %>
251
+ ```
252
+
253
+ ## Customizing Views
254
+
255
+ Override views by creating matching files in your app:
256
+
257
+ ```
258
+ app/views/solid_log/ui/
259
+ ├── streams/
260
+ │ └── index.html.erb # Override streams view
261
+ ├── entries/
262
+ │ └── show.html.erb # Override entry detail view
263
+ └── layouts/
264
+ └── solid_log/
265
+ └── ui/
266
+ └── application.html.erb # Override layout
267
+ ```
268
+
269
+ ## Customizing Styles
270
+
271
+ Add custom CSS in your application:
272
+
273
+ ```css
274
+ /* app/assets/stylesheets/solid_log_custom.css */
275
+ .solid-log-stream-entry {
276
+ border-left: 4px solid #your-brand-color;
277
+ }
278
+ ```
279
+
280
+ Then import in your application.css:
281
+
282
+ ```css
283
+ @import "solid_log_custom";
284
+ ```
285
+
286
+ ## Development
287
+
288
+ ```bash
289
+ cd solid_log-ui
290
+ bundle install
291
+ ```
292
+
293
+ ## License
294
+
295
+ 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
+ })();