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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +295 -0
- data/Rakefile +12 -0
- data/app/assets/javascripts/application.js +6 -0
- data/app/assets/javascripts/solid_log/checkbox_dropdown.js +171 -0
- data/app/assets/javascripts/solid_log/filter_state.js +138 -0
- data/app/assets/javascripts/solid_log/jump_to_live.js +119 -0
- data/app/assets/javascripts/solid_log/live_tail.js +476 -0
- data/app/assets/javascripts/solid_log/live_tail.js.bak +270 -0
- data/app/assets/javascripts/solid_log/log_filters.js +37 -0
- data/app/assets/javascripts/solid_log/stream_scroll.js +195 -0
- data/app/assets/javascripts/solid_log/timeline_histogram.js +162 -0
- data/app/assets/javascripts/solid_log/toast.js +50 -0
- data/app/assets/stylesheets/solid_log/application.css +1329 -0
- data/app/assets/stylesheets/solid_log/components.css +1506 -0
- data/app/assets/stylesheets/solid_log/mission_control.css +398 -0
- data/app/channels/solid_log/ui/application_cable/channel.rb +8 -0
- data/app/channels/solid_log/ui/application_cable/connection.rb +10 -0
- data/app/channels/solid_log/ui/log_stream_channel.rb +132 -0
- data/app/controllers/solid_log/ui/base_controller.rb +122 -0
- data/app/controllers/solid_log/ui/dashboard_controller.rb +32 -0
- data/app/controllers/solid_log/ui/entries_controller.rb +34 -0
- data/app/controllers/solid_log/ui/fields_controller.rb +57 -0
- data/app/controllers/solid_log/ui/streams_controller.rb +204 -0
- data/app/controllers/solid_log/ui/timelines_controller.rb +29 -0
- data/app/controllers/solid_log/ui/tokens_controller.rb +46 -0
- data/app/helpers/solid_log/ui/application_helper.rb +99 -0
- data/app/helpers/solid_log/ui/dashboard_helper.rb +46 -0
- data/app/helpers/solid_log/ui/entries_helper.rb +16 -0
- data/app/helpers/solid_log/ui/timeline_helper.rb +39 -0
- data/app/services/solid_log/ui/live_tail_broadcaster.rb +81 -0
- data/app/views/layouts/solid_log/ui/application.html.erb +53 -0
- data/app/views/solid_log/ui/dashboard/index.html.erb +178 -0
- data/app/views/solid_log/ui/entries/show.html.erb +132 -0
- data/app/views/solid_log/ui/fields/index.html.erb +133 -0
- data/app/views/solid_log/ui/shared/_checkbox_dropdown.html.erb +64 -0
- data/app/views/solid_log/ui/shared/_multiselect_filter.html.erb +37 -0
- data/app/views/solid_log/ui/shared/_toast.html.erb +7 -0
- data/app/views/solid_log/ui/shared/_toast_message.html.erb +30 -0
- data/app/views/solid_log/ui/streams/_filter_form.html.erb +207 -0
- data/app/views/solid_log/ui/streams/_footer.html.erb +37 -0
- data/app/views/solid_log/ui/streams/_log_entries.html.erb +5 -0
- data/app/views/solid_log/ui/streams/_log_row.html.erb +5 -0
- data/app/views/solid_log/ui/streams/_log_row_compact.html.erb +37 -0
- data/app/views/solid_log/ui/streams/_log_row_expanded.html.erb +67 -0
- data/app/views/solid_log/ui/streams/_log_stream_content.html.erb +8 -0
- data/app/views/solid_log/ui/streams/_timeline.html.erb +68 -0
- data/app/views/solid_log/ui/streams/index.html.erb +22 -0
- data/app/views/solid_log/ui/timelines/show_job.html.erb +78 -0
- data/app/views/solid_log/ui/timelines/show_request.html.erb +88 -0
- data/app/views/solid_log/ui/tokens/index.html.erb +95 -0
- data/app/views/solid_log/ui/tokens/new.html.erb +47 -0
- data/config/importmap.rb +15 -0
- data/config/routes.rb +27 -0
- data/lib/solid_log/ui/api_client.rb +117 -0
- data/lib/solid_log/ui/configuration.rb +99 -0
- data/lib/solid_log/ui/data_source.rb +146 -0
- data/lib/solid_log/ui/engine.rb +76 -0
- data/lib/solid_log/ui/version.rb +5 -0
- data/lib/solid_log/ui.rb +27 -0
- data/lib/solid_log-ui.rb +2 -0
- 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,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
|
+
})();
|