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
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/* SolidLog - Mission Control Inspired Styles */
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--color-primary: #2563eb;
|
|
5
|
+
--color-success: #10b981;
|
|
6
|
+
--color-warning: #f59e0b;
|
|
7
|
+
--color-danger: #ef4444;
|
|
8
|
+
--color-info: #3b82f6;
|
|
9
|
+
|
|
10
|
+
--color-gray-50: #f9fafb;
|
|
11
|
+
--color-gray-100: #f3f4f6;
|
|
12
|
+
--color-gray-200: #e5e7eb;
|
|
13
|
+
--color-gray-300: #d1d5db;
|
|
14
|
+
--color-gray-700: #374151;
|
|
15
|
+
--color-gray-800: #1f2937;
|
|
16
|
+
--color-gray-900: #111827;
|
|
17
|
+
|
|
18
|
+
--spacing-sm: 0.5rem;
|
|
19
|
+
--spacing-md: 1rem;
|
|
20
|
+
--spacing-lg: 1.5rem;
|
|
21
|
+
--spacing-xl: 2rem;
|
|
22
|
+
|
|
23
|
+
--border-radius: 0.375rem;
|
|
24
|
+
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
25
|
+
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
body {
|
|
29
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
30
|
+
font-size: 14px;
|
|
31
|
+
line-height: 1.5;
|
|
32
|
+
color: var(--color-gray-900);
|
|
33
|
+
background: var(--color-gray-50);
|
|
34
|
+
margin: 0;
|
|
35
|
+
padding: 0;
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-direction: column;
|
|
38
|
+
min-height: 100vh;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Navigation */
|
|
42
|
+
.top-nav {
|
|
43
|
+
background: white;
|
|
44
|
+
border-bottom: 1px solid var(--color-gray-200);
|
|
45
|
+
padding: 0 var(--spacing-xl);
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: var(--spacing-xl);
|
|
49
|
+
height: 60px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.nav-brand {
|
|
53
|
+
font-size: 1.25rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.brand-link {
|
|
57
|
+
color: var(--color-gray-900);
|
|
58
|
+
text-decoration: none;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.brand-link:hover {
|
|
62
|
+
color: var(--color-primary);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.nav-links {
|
|
66
|
+
display: flex;
|
|
67
|
+
gap: var(--spacing-md);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.nav-link {
|
|
71
|
+
padding: 0.5rem 1rem;
|
|
72
|
+
color: var(--color-gray-700);
|
|
73
|
+
text-decoration: none;
|
|
74
|
+
font-weight: 500;
|
|
75
|
+
border-radius: var(--border-radius);
|
|
76
|
+
transition: all 0.15s ease;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.nav-link:hover {
|
|
80
|
+
background: var(--color-gray-100);
|
|
81
|
+
color: var(--color-gray-900);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.nav-link.active {
|
|
85
|
+
background: var(--color-primary);
|
|
86
|
+
color: white;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Main Content */
|
|
90
|
+
.main-content {
|
|
91
|
+
flex: 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Footer */
|
|
95
|
+
.footer {
|
|
96
|
+
background: white;
|
|
97
|
+
border-top: 1px solid var(--color-gray-200);
|
|
98
|
+
padding: var(--spacing-md) var(--spacing-xl);
|
|
99
|
+
text-align: center;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.footer-content {
|
|
103
|
+
font-size: 0.75rem;
|
|
104
|
+
color: var(--color-gray-700);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* Page Header */
|
|
108
|
+
.page-header {
|
|
109
|
+
padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-lg);
|
|
110
|
+
background: white;
|
|
111
|
+
border-bottom: 1px solid var(--color-gray-200);
|
|
112
|
+
display: flex;
|
|
113
|
+
justify-content: space-between;
|
|
114
|
+
align-items: center;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.page-header h1 {
|
|
118
|
+
margin: 0;
|
|
119
|
+
font-size: 1.875rem;
|
|
120
|
+
font-weight: 700;
|
|
121
|
+
color: var(--color-gray-900);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.page-header .subtitle {
|
|
125
|
+
margin: 0.25rem 0 0;
|
|
126
|
+
color: var(--color-gray-700);
|
|
127
|
+
font-size: 0.875rem;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.page-actions {
|
|
131
|
+
display: flex;
|
|
132
|
+
gap: var(--spacing-sm);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* Dashboard */
|
|
136
|
+
.dashboard {
|
|
137
|
+
max-width: 1400px;
|
|
138
|
+
margin: 0 auto;
|
|
139
|
+
padding: var(--spacing-xl);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.stats-grid {
|
|
143
|
+
display: grid;
|
|
144
|
+
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
145
|
+
gap: var(--spacing-lg);
|
|
146
|
+
margin-bottom: var(--spacing-xl);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.stat-card {
|
|
150
|
+
background: white;
|
|
151
|
+
border-radius: var(--border-radius);
|
|
152
|
+
padding: var(--spacing-lg);
|
|
153
|
+
box-shadow: var(--shadow-sm);
|
|
154
|
+
border: 1px solid var(--color-gray-200);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.stat-label {
|
|
158
|
+
font-size: 0.875rem;
|
|
159
|
+
color: var(--color-gray-700);
|
|
160
|
+
font-weight: 500;
|
|
161
|
+
margin-bottom: var(--spacing-sm);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.stat-value {
|
|
165
|
+
font-size: 2rem;
|
|
166
|
+
font-weight: 700;
|
|
167
|
+
color: var(--color-gray-900);
|
|
168
|
+
margin-bottom: var(--spacing-sm);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.stat-value.error-count {
|
|
172
|
+
color: var(--color-danger);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.stat-footer {
|
|
176
|
+
font-size: 0.75rem;
|
|
177
|
+
color: var(--color-gray-700);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.stat-link {
|
|
181
|
+
color: var(--color-primary);
|
|
182
|
+
text-decoration: none;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.stat-link:hover {
|
|
186
|
+
text-decoration: underline;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* Dashboard Row */
|
|
190
|
+
.dashboard-row {
|
|
191
|
+
display: grid;
|
|
192
|
+
grid-template-columns: repeat(12, 1fr);
|
|
193
|
+
gap: var(--spacing-lg);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.dashboard-col-8 {
|
|
197
|
+
grid-column: span 8;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.dashboard-col-4 {
|
|
201
|
+
grid-column: span 4;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@media (max-width: 768px) {
|
|
205
|
+
.dashboard-col-8,
|
|
206
|
+
.dashboard-col-4 {
|
|
207
|
+
grid-column: span 12;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Card */
|
|
212
|
+
.card {
|
|
213
|
+
background: white;
|
|
214
|
+
border-radius: var(--border-radius);
|
|
215
|
+
box-shadow: var(--shadow-sm);
|
|
216
|
+
border: 1px solid var(--color-gray-200);
|
|
217
|
+
overflow: hidden;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.card-header {
|
|
221
|
+
padding: var(--spacing-md) var(--spacing-lg);
|
|
222
|
+
border-bottom: 1px solid var(--color-gray-200);
|
|
223
|
+
background: var(--color-gray-50);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.card-header h2 {
|
|
227
|
+
margin: 0;
|
|
228
|
+
font-size: 1.125rem;
|
|
229
|
+
font-weight: 600;
|
|
230
|
+
color: var(--color-gray-900);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.card-body {
|
|
234
|
+
padding: var(--spacing-lg);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.empty-state {
|
|
238
|
+
text-align: center;
|
|
239
|
+
padding: var(--spacing-xl);
|
|
240
|
+
color: var(--color-gray-700);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* Log Items */
|
|
244
|
+
.log-list {
|
|
245
|
+
display: flex;
|
|
246
|
+
flex-direction: column;
|
|
247
|
+
gap: var(--spacing-md);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.log-item {
|
|
251
|
+
padding: var(--spacing-md);
|
|
252
|
+
border: 1px solid var(--color-gray-200);
|
|
253
|
+
border-radius: var(--border-radius);
|
|
254
|
+
background: var(--color-gray-50);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.log-item-header {
|
|
258
|
+
display: flex;
|
|
259
|
+
align-items: center;
|
|
260
|
+
gap: var(--spacing-sm);
|
|
261
|
+
margin-bottom: var(--spacing-sm);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.log-time {
|
|
265
|
+
font-size: 0.75rem;
|
|
266
|
+
color: var(--color-gray-700);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.log-message {
|
|
270
|
+
font-size: 0.875rem;
|
|
271
|
+
color: var(--color-gray-900);
|
|
272
|
+
margin-bottom: var(--spacing-sm);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.log-message a {
|
|
276
|
+
color: var(--color-gray-900);
|
|
277
|
+
text-decoration: none;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.log-message a:hover {
|
|
281
|
+
color: var(--color-primary);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.log-meta {
|
|
285
|
+
font-size: 0.75rem;
|
|
286
|
+
color: var(--color-gray-700);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* Distribution */
|
|
290
|
+
.distribution-list {
|
|
291
|
+
display: flex;
|
|
292
|
+
flex-direction: column;
|
|
293
|
+
gap: var(--spacing-md);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.distribution-item {
|
|
297
|
+
display: flex;
|
|
298
|
+
flex-direction: column;
|
|
299
|
+
gap: 0.25rem;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.distribution-header {
|
|
303
|
+
display: flex;
|
|
304
|
+
justify-content: space-between;
|
|
305
|
+
align-items: center;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.distribution-count {
|
|
309
|
+
font-weight: 600;
|
|
310
|
+
color: var(--color-gray-900);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.distribution-bar-container {
|
|
314
|
+
height: 8px;
|
|
315
|
+
background: var(--color-gray-200);
|
|
316
|
+
border-radius: 4px;
|
|
317
|
+
overflow: hidden;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.distribution-bar {
|
|
321
|
+
height: 100%;
|
|
322
|
+
background: var(--color-primary);
|
|
323
|
+
transition: width 0.3s ease;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.distribution-percentage {
|
|
327
|
+
font-size: 0.75rem;
|
|
328
|
+
color: var(--color-gray-700);
|
|
329
|
+
text-align: right;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/* Quick Stats */
|
|
333
|
+
.quick-stats {
|
|
334
|
+
display: flex;
|
|
335
|
+
flex-direction: column;
|
|
336
|
+
gap: var(--spacing-md);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.quick-stat {
|
|
340
|
+
display: flex;
|
|
341
|
+
justify-content: space-between;
|
|
342
|
+
padding: var(--spacing-sm) 0;
|
|
343
|
+
border-bottom: 1px solid var(--color-gray-200);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.quick-stat:last-child {
|
|
347
|
+
border-bottom: none;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.quick-stat-label {
|
|
351
|
+
font-size: 0.875rem;
|
|
352
|
+
color: var(--color-gray-700);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.quick-stat-value {
|
|
356
|
+
font-size: 0.875rem;
|
|
357
|
+
font-weight: 600;
|
|
358
|
+
color: var(--color-gray-900);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* Recommendations */
|
|
362
|
+
.recommendation-list {
|
|
363
|
+
display: flex;
|
|
364
|
+
flex-direction: column;
|
|
365
|
+
gap: var(--spacing-md);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.recommendation-item {
|
|
369
|
+
padding: var(--spacing-md);
|
|
370
|
+
background: var(--color-gray-50);
|
|
371
|
+
border: 1px solid var(--color-gray-200);
|
|
372
|
+
border-radius: var(--border-radius);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.recommendation-header {
|
|
376
|
+
display: flex;
|
|
377
|
+
justify-content: space-between;
|
|
378
|
+
align-items: center;
|
|
379
|
+
margin-bottom: var(--spacing-sm);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.recommendation-header code {
|
|
383
|
+
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
|
|
384
|
+
font-size: 0.875rem;
|
|
385
|
+
font-weight: 600;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.recommendation-meta {
|
|
389
|
+
font-size: 0.8125rem;
|
|
390
|
+
color: var(--color-gray-600);
|
|
391
|
+
margin-bottom: var(--spacing-sm);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.recommendation-action {
|
|
395
|
+
font-size: 0.875rem;
|
|
396
|
+
color: var(--color-gray-700);
|
|
397
|
+
line-height: 1.5;
|
|
398
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
module SolidLog
|
|
2
|
+
module UI
|
|
3
|
+
class LogStreamChannel < ApplicationCable::Channel
|
|
4
|
+
CACHE_NAMESPACE = "solid_log:active_filters"
|
|
5
|
+
CACHE_EXPIRY = 5.minutes
|
|
6
|
+
|
|
7
|
+
def subscribed
|
|
8
|
+
# Store the filters for this subscription
|
|
9
|
+
@filters = params[:filters] || {}
|
|
10
|
+
@filter_key = generate_filter_key(@filters)
|
|
11
|
+
|
|
12
|
+
# Subscribe to new entries broadcast from service
|
|
13
|
+
# Service broadcasts entry IDs, we filter and render them
|
|
14
|
+
stream_from "solid_log_new_entries", coder: ActiveSupport::JSON do |data|
|
|
15
|
+
handle_new_entries(data["entry_ids"]) if data["entry_ids"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Register this filter combination in Rails.cache
|
|
19
|
+
# Expires after 5 minutes of inactivity (refreshed on heartbeat)
|
|
20
|
+
cache_key = "#{CACHE_NAMESPACE}:#{@filter_key}"
|
|
21
|
+
Rails.cache.write(cache_key, @filters, expires_in: CACHE_EXPIRY)
|
|
22
|
+
|
|
23
|
+
# Also add to the set of active filter keys
|
|
24
|
+
register_active_filter_key(@filter_key)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def unsubscribed
|
|
28
|
+
# Cleanup when channel is unsubscribed
|
|
29
|
+
stop_all_streams
|
|
30
|
+
|
|
31
|
+
# Cache entries will expire naturally after CACHE_EXPIRY
|
|
32
|
+
# This handles the case where multiple clients use same filters
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def refresh_subscription
|
|
36
|
+
# Called periodically by client to keep subscription active in cache
|
|
37
|
+
# This prevents the filter from expiring while user is actively watching
|
|
38
|
+
if @filter_key
|
|
39
|
+
cache_key = "#{CACHE_NAMESPACE}:#{@filter_key}"
|
|
40
|
+
Rails.cache.write(cache_key, @filters, expires_in: CACHE_EXPIRY)
|
|
41
|
+
register_active_filter_key(@filter_key)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.active_filter_combinations
|
|
46
|
+
# Read all active filter combinations from cache
|
|
47
|
+
active_keys = Rails.cache.read("#{CACHE_NAMESPACE}:keys") || []
|
|
48
|
+
|
|
49
|
+
filters_hash = {}
|
|
50
|
+
active_keys.each do |key|
|
|
51
|
+
cache_key = "#{CACHE_NAMESPACE}:#{key}"
|
|
52
|
+
filters = Rails.cache.read(cache_key)
|
|
53
|
+
filters_hash[key] = filters if filters
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
filters_hash
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def generate_filter_key(filters)
|
|
62
|
+
# Create a consistent hash based on filter values
|
|
63
|
+
# Sort to ensure same filters = same key regardless of order
|
|
64
|
+
normalized = filters.sort.to_h
|
|
65
|
+
Digest::MD5.hexdigest(normalized.to_json)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def register_active_filter_key(filter_key)
|
|
69
|
+
# Maintain a list of active filter keys in cache
|
|
70
|
+
keys_cache_key = "#{CACHE_NAMESPACE}:keys"
|
|
71
|
+
|
|
72
|
+
# Read current keys, add this one, write back
|
|
73
|
+
# Note: This has a race condition but it's acceptable for this use case
|
|
74
|
+
current_keys = Rails.cache.read(keys_cache_key) || []
|
|
75
|
+
current_keys << filter_key unless current_keys.include?(filter_key)
|
|
76
|
+
|
|
77
|
+
# Keep the list alive as long as any filter is active
|
|
78
|
+
Rails.cache.write(keys_cache_key, current_keys.uniq, expires_in: CACHE_EXPIRY)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def handle_new_entries(entry_ids)
|
|
82
|
+
return if entry_ids.blank?
|
|
83
|
+
|
|
84
|
+
Rails.logger.info "[LogStreamChannel] Received broadcast with #{entry_ids.size} entry IDs: #{entry_ids.first(5)}"
|
|
85
|
+
|
|
86
|
+
# Fetch entries matching these IDs
|
|
87
|
+
entries = SolidLog::Entry.where(id: entry_ids).order(:id)
|
|
88
|
+
Rails.logger.info "[LogStreamChannel] Found #{entries.size} entries in database"
|
|
89
|
+
|
|
90
|
+
# Filter to only entries matching this client's filters
|
|
91
|
+
transmitted_count = 0
|
|
92
|
+
entries.each do |entry|
|
|
93
|
+
matches = entry_matches_filters?(entry)
|
|
94
|
+
Rails.logger.debug "[LogStreamChannel] Entry #{entry.id} matches filters: #{matches}"
|
|
95
|
+
next unless matches
|
|
96
|
+
|
|
97
|
+
# Render HTML for this specific entry with proper route context
|
|
98
|
+
html = SolidLog::UI::BaseController.render(
|
|
99
|
+
partial: "solid_log/ui/streams/log_row",
|
|
100
|
+
locals: { entry: entry, query: nil },
|
|
101
|
+
layout: false
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Transmit to this specific client
|
|
105
|
+
transmit({ html: html, entry_id: entry.id })
|
|
106
|
+
transmitted_count += 1
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
Rails.logger.info "[LogStreamChannel] Transmitted #{transmitted_count} entries to client (filter: #{@filter_key})"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def entry_matches_filters?(entry)
|
|
113
|
+
return true if @filters.blank?
|
|
114
|
+
|
|
115
|
+
# Check each filter condition
|
|
116
|
+
@filters.each do |key, values|
|
|
117
|
+
values = Array(values).reject(&:blank?)
|
|
118
|
+
next if values.empty?
|
|
119
|
+
|
|
120
|
+
entry_value = entry.public_send(key) rescue nil
|
|
121
|
+
return false if entry_value.nil?
|
|
122
|
+
|
|
123
|
+
unless values.map(&:to_s).include?(entry_value.to_s)
|
|
124
|
+
return false
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
true
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module SolidLog
|
|
2
|
+
module UI
|
|
3
|
+
# Dynamically inherit from configured base controller
|
|
4
|
+
base_controller_class = begin
|
|
5
|
+
SolidLog::UI.configuration.base_controller.constantize
|
|
6
|
+
rescue NameError
|
|
7
|
+
# Fallback to ActionController::Base if configuration not set or class doesn't exist
|
|
8
|
+
ActionController::Base
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class BaseController < base_controller_class
|
|
12
|
+
include Turbo::Streams::TurboStreamsTagBuilder
|
|
13
|
+
helper Turbo::Engine.helpers
|
|
14
|
+
helper Importmap::ImportmapTagsHelper
|
|
15
|
+
|
|
16
|
+
# Explicitly include engine helpers (now that they're in correct namespace path)
|
|
17
|
+
helper SolidLog::UI::ApplicationHelper
|
|
18
|
+
helper SolidLog::UI::DashboardHelper
|
|
19
|
+
helper SolidLog::UI::EntriesHelper
|
|
20
|
+
helper SolidLog::UI::TimelineHelper
|
|
21
|
+
|
|
22
|
+
layout "solid_log/ui/application"
|
|
23
|
+
|
|
24
|
+
before_action :authenticate_user!
|
|
25
|
+
before_action :set_data_source
|
|
26
|
+
|
|
27
|
+
# Override this method in your host application to implement custom authentication
|
|
28
|
+
#
|
|
29
|
+
# Example configurations in config/initializers/solid_log_ui.rb:
|
|
30
|
+
#
|
|
31
|
+
# 1. Using a Proc/Lambda:
|
|
32
|
+
# SolidLog::UI.configure do |config|
|
|
33
|
+
# config.authentication_method = ->(controller) {
|
|
34
|
+
# controller.redirect_to controller.root_path unless controller.current_user&.admin?
|
|
35
|
+
# }
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# 2. Using a method name:
|
|
39
|
+
# SolidLog::UI.configure do |config|
|
|
40
|
+
# config.authentication_method = :require_admin
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# class ApplicationController
|
|
44
|
+
# def require_admin
|
|
45
|
+
# redirect_to root_path unless current_user&.admin?
|
|
46
|
+
# end
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# 3. Using basic auth:
|
|
50
|
+
# SolidLog::UI.configure do |config|
|
|
51
|
+
# config.authentication_method = :basic
|
|
52
|
+
# end
|
|
53
|
+
#
|
|
54
|
+
def authenticate_user!
|
|
55
|
+
config = SolidLog::UI.configuration
|
|
56
|
+
auth_method = config.authentication_method
|
|
57
|
+
|
|
58
|
+
case auth_method
|
|
59
|
+
when :none
|
|
60
|
+
# No authentication required
|
|
61
|
+
true
|
|
62
|
+
when :basic
|
|
63
|
+
authenticate_or_request_with_http_basic("SolidLog") do |username, password|
|
|
64
|
+
authenticate_with_basic_auth(username, password)
|
|
65
|
+
end
|
|
66
|
+
when Proc
|
|
67
|
+
# Call the proc in the controller's context
|
|
68
|
+
instance_exec(&auth_method)
|
|
69
|
+
when Symbol
|
|
70
|
+
# Call the named method on the controller
|
|
71
|
+
if respond_to?(auth_method, true)
|
|
72
|
+
send(auth_method)
|
|
73
|
+
else
|
|
74
|
+
raise NoMethodError, "Authentication method '#{auth_method}' not defined. Define it in your ApplicationController or BaseController."
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
render plain: "Invalid authentication configuration", status: :unauthorized
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
protected
|
|
83
|
+
|
|
84
|
+
# Override this in host app to customize basic auth credentials
|
|
85
|
+
def authenticate_with_basic_auth(username, password)
|
|
86
|
+
# Default: check Rails credentials
|
|
87
|
+
credentials = Rails.application.credentials.solidlog || {}
|
|
88
|
+
username == credentials[:username] && password == credentials[:password]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Helper to check if user is authenticated
|
|
92
|
+
def authenticated?
|
|
93
|
+
auth_method = SolidLog::UI.configuration.authentication_method
|
|
94
|
+
|
|
95
|
+
case auth_method
|
|
96
|
+
when :none
|
|
97
|
+
true
|
|
98
|
+
when :basic
|
|
99
|
+
request.authorization.present?
|
|
100
|
+
when Proc, Symbol
|
|
101
|
+
# For custom auth (proc or method name), assume authenticated if we got this far
|
|
102
|
+
# (since authenticate_user! would have redirected/rendered if not authenticated)
|
|
103
|
+
true
|
|
104
|
+
else
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Current user - override in host app if using custom auth
|
|
110
|
+
def current_user
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
helper_method :current_user
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def set_data_source
|
|
118
|
+
@data_source = SolidLog::UI::DataSource.new
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module SolidLog
|
|
2
|
+
module UI
|
|
3
|
+
class DashboardController < BaseController
|
|
4
|
+
def index
|
|
5
|
+
@health_metrics = SolidLog.without_logging { SolidLog::HealthService.metrics }
|
|
6
|
+
@recent_errors = recent_error_entries
|
|
7
|
+
@log_level_distribution = log_level_distribution
|
|
8
|
+
@field_recommendations = field_recommendations
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def recent_error_entries
|
|
14
|
+
SolidLog.without_logging do
|
|
15
|
+
Entry.errors.recent.limit(10)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def log_level_distribution
|
|
20
|
+
SolidLog.without_logging do
|
|
21
|
+
Entry.group(:level).count
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def field_recommendations
|
|
26
|
+
SolidLog.without_logging do
|
|
27
|
+
SolidLog::FieldAnalyzer.analyze.take(5)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|