onlylogs 0.9.0 → 0.11.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 +4 -4
- data/README.md +39 -2
- data/app/channels/onlylogs/logs_channel.rb +90 -70
- data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +15 -11
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +177 -203
- data/app/javascript/onlylogs/controllers/range_slider_controller.js +96 -0
- data/app/javascript/onlylogs/controllers/text_selection_controller.js +1 -0
- data/app/models/onlylogs/file.rb +13 -2
- data/app/models/onlylogs/grep.rb +14 -6
- data/app/views/onlylogs/logs/index.html.erb +25 -5
- data/app/views/onlylogs/shared/_log_container.html.erb +84 -37
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +247 -49
- data/app/views/onlylogs/shared/_range_slider.html.erb +9 -5
- data/lib/onlylogs/continuous_log_writer.rb +161 -0
- data/lib/onlylogs/formatter.rb +4 -3
- data/lib/onlylogs/http_device.rb +319 -84
- data/lib/onlylogs/http_logger.rb +4 -7
- data/lib/onlylogs/spool.rb +61 -13
- data/lib/onlylogs/version.rb +1 -1
- metadata +3 -1
|
@@ -5,16 +5,20 @@ export default class LogStreamerController extends Controller {
|
|
|
5
5
|
static values = {
|
|
6
6
|
filePath: { type: String },
|
|
7
7
|
autoScroll: { type: Boolean, default: true },
|
|
8
|
-
autoStart: { type: Boolean, default: true },
|
|
9
8
|
filter: { type: String, default: '' },
|
|
10
9
|
mode: { type: String, default: 'live' },
|
|
10
|
+
// false for a file nothing writes to any more: the viewer only searches, never tails.
|
|
11
|
+
liveEnabled: { type: Boolean, default: true },
|
|
11
12
|
regexpMode: { type: Boolean, default: false },
|
|
12
13
|
fileSize: { type: Number, default: 0 },
|
|
13
14
|
startPosition: { type: Number, default: 0 },
|
|
14
15
|
endPosition: { type: Number, default: 0 }
|
|
15
16
|
};
|
|
16
17
|
|
|
17
|
-
static targets = ["logLines", "filterInput", "results", "
|
|
18
|
+
static targets = ["logLines", "filterInput", "results", "liveButton", "searchButton", "searchWholeFileButton", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll", "rangeSliderContainer", "startSlider", "endSlider", "searchPlaceholder"];
|
|
19
|
+
|
|
20
|
+
// When the current live query started, so "0 matches" can say since when.
|
|
21
|
+
#liveFilterStartedAt = null;
|
|
18
22
|
|
|
19
23
|
connect() {
|
|
20
24
|
this.consumer = createConsumer();
|
|
@@ -23,7 +27,6 @@ export default class LogStreamerController extends Controller {
|
|
|
23
27
|
this.isRunning = false;
|
|
24
28
|
this.reconnectTimeout = null;
|
|
25
29
|
this.isSearchFinished = true;
|
|
26
|
-
this.lastRangeStep = null;
|
|
27
30
|
this.historyUpdateTimeout = null;
|
|
28
31
|
this.contextLineHighlighted = false;
|
|
29
32
|
|
|
@@ -33,13 +36,6 @@ export default class LogStreamerController extends Controller {
|
|
|
33
36
|
|
|
34
37
|
this.#updateWebsocketStatus('disconnected');
|
|
35
38
|
|
|
36
|
-
// Listen for range-slider updates
|
|
37
|
-
if (this.hasRangeSliderContainerTarget) {
|
|
38
|
-
this.rangeSliderContainerTarget.addEventListener('range:update', (e) => {
|
|
39
|
-
this.#handleRangeUpdate(e);
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
39
|
// Restore state from URL params if present
|
|
44
40
|
this.#restoreStateFromUrl();
|
|
45
41
|
|
|
@@ -111,17 +107,13 @@ export default class LogStreamerController extends Controller {
|
|
|
111
107
|
}
|
|
112
108
|
|
|
113
109
|
pauseForSelection() {
|
|
114
|
-
// Triggered by TextSelectionController#handleMouseDown via text-selection:start event
|
|
115
|
-
//
|
|
110
|
+
// Triggered by TextSelectionController#handleMouseDown via text-selection:start event.
|
|
111
|
+
// Stopping autoscroll is enough to keep the text still under the cursor; the mode
|
|
112
|
+
// is the user's to change, so selecting text must never switch it.
|
|
116
113
|
if (this.autoScrollValue) {
|
|
117
114
|
this.autoScrollValue = false;
|
|
118
115
|
this.autoscrollTarget.checked = false;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (this.isLiveMode()) {
|
|
122
|
-
this.liveModeTarget.checked = false;
|
|
123
|
-
this.modeValue = 'static';
|
|
124
|
-
this.stop();
|
|
116
|
+
this.#updateUrlParam('autoscroll', 'false');
|
|
125
117
|
}
|
|
126
118
|
}
|
|
127
119
|
|
|
@@ -134,32 +126,34 @@ export default class LogStreamerController extends Controller {
|
|
|
134
126
|
}
|
|
135
127
|
}
|
|
136
128
|
|
|
137
|
-
|
|
138
|
-
if (this.isLiveMode())
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
129
|
+
switchToLive() {
|
|
130
|
+
if (this.isLiveMode() || !this.liveEnabledValue) return;
|
|
131
|
+
|
|
132
|
+
this.#clearHighlighting();
|
|
133
|
+
this.#setMode('live');
|
|
134
|
+
this.clear();
|
|
135
|
+
this.#setRange(0, this.fileSizeValue);
|
|
136
|
+
this.#updateUrlParams({ start_position: null, end_position: null, byte_offset: null });
|
|
137
|
+
this.reconnectWithNewMode();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
switchToSearch() {
|
|
141
|
+
if (!this.isLiveMode()) return;
|
|
142
|
+
|
|
143
|
+
this.#setMode('static');
|
|
144
|
+
this.reconnectWithNewMode();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Bound only to the "Search whole file" button, which is shown while tailing.
|
|
148
|
+
// Leaves live mode: runs what is already typed against the file instead of
|
|
149
|
+
// waiting for a matching line to arrive.
|
|
150
|
+
searchWholeFile() {
|
|
151
|
+
this.#clearHighlighting();
|
|
152
|
+
this.#setMode('static');
|
|
153
|
+
this.#setRange(0, this.fileSizeValue);
|
|
154
|
+
this.#updateUrlParams({ start_position: null, end_position: null, byte_offset: null });
|
|
155
|
+
this.reconnectWithNewMode();
|
|
156
|
+
this.filterInputTarget.focus();
|
|
163
157
|
}
|
|
164
158
|
|
|
165
159
|
applyFilter() {
|
|
@@ -172,14 +166,42 @@ export default class LogStreamerController extends Controller {
|
|
|
172
166
|
// Update visual state
|
|
173
167
|
this.updateStopButtonVisibility();
|
|
174
168
|
this.#updateUrlParam('filter', filterValue || null);
|
|
175
|
-
this.#
|
|
169
|
+
this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
|
|
170
|
+
this.#updateResultsDisplay();
|
|
176
171
|
|
|
177
172
|
// Use the global debounced reconnection (300ms delay)
|
|
178
173
|
this.reconnectWithNewMode();
|
|
179
174
|
}
|
|
180
175
|
|
|
181
176
|
isLiveMode() {
|
|
182
|
-
return this.
|
|
177
|
+
return this.modeValue === 'live';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The only writer of modeValue. Everything that changes the mode goes through
|
|
181
|
+
// here so the switch, the URL and the toolbar layout can never disagree.
|
|
182
|
+
#setMode(mode) {
|
|
183
|
+
if (mode === 'live' && !this.liveEnabledValue) return;
|
|
184
|
+
|
|
185
|
+
this.modeValue = mode;
|
|
186
|
+
// Search is the only mode when live is disabled, so the URL need not say so.
|
|
187
|
+
this.#updateUrlParam('mode', mode === 'live' || !this.liveEnabledValue ? null : 'static');
|
|
188
|
+
this.#liveFilterStartedAt = mode === 'live' ? new Date() : null;
|
|
189
|
+
this.#syncModeControls();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#syncModeControls() {
|
|
193
|
+
const live = this.isLiveMode();
|
|
194
|
+
this.#syncSearchPlaceholder();
|
|
195
|
+
|
|
196
|
+
if (this.hasLiveButtonTarget) {
|
|
197
|
+
this.liveButtonTarget.setAttribute('aria-pressed', live);
|
|
198
|
+
}
|
|
199
|
+
if (this.hasSearchButtonTarget) {
|
|
200
|
+
this.searchButtonTarget.setAttribute('aria-pressed', !live);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this.filterInputTarget.placeholder = live ? 'follow lines matching…' : 'search the whole file…';
|
|
204
|
+
this.#updateResultsDisplay();
|
|
183
205
|
}
|
|
184
206
|
|
|
185
207
|
scroll() {
|
|
@@ -210,34 +232,16 @@ export default class LogStreamerController extends Controller {
|
|
|
210
232
|
}, 600);
|
|
211
233
|
}
|
|
212
234
|
|
|
235
|
+
// Clears the text only. The mode and the range belong to their own controls,
|
|
236
|
+
// so an × on a text field must not quietly reach over and change them.
|
|
213
237
|
clearFilter() {
|
|
214
|
-
// Clear filter and explore window to go back to pure live mode
|
|
215
238
|
this.filterInputTarget.value = '';
|
|
216
|
-
this.modeValue = 'live';
|
|
217
|
-
this.startPositionValue = 0;
|
|
218
|
-
this.endPositionValue = this.fileSizeValue;
|
|
219
|
-
|
|
220
|
-
// Clear highlighting
|
|
221
239
|
this.#clearHighlighting();
|
|
222
|
-
|
|
223
|
-
// Re-enable live mode checkbox
|
|
224
|
-
this.liveModeTarget.checked = true;
|
|
225
|
-
|
|
226
|
-
// Reset range to default for live mode
|
|
227
|
-
this.#setRange(0, this.fileSizeValue);
|
|
228
|
-
|
|
229
|
-
// Update visual state
|
|
230
240
|
this.updateStopButtonVisibility();
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
this.#updateUrlParam('filter', null);
|
|
234
|
-
this.#updateUrlParam('byte_offset', null);
|
|
235
|
-
this.#updateUrlParam('mode', null);
|
|
236
|
-
this.#updateUrlParam('start_position', null);
|
|
237
|
-
this.#updateUrlParam('end_position', null);
|
|
238
|
-
|
|
239
|
-
// Reconnect with cleared filter and live mode
|
|
241
|
+
this.#updateUrlParams({ filter: null, byte_offset: null });
|
|
242
|
+
this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
|
|
240
243
|
this.reconnectWithNewMode();
|
|
244
|
+
this.filterInputTarget.focus();
|
|
241
245
|
}
|
|
242
246
|
|
|
243
247
|
stopSearch() {
|
|
@@ -257,10 +261,10 @@ export default class LogStreamerController extends Controller {
|
|
|
257
261
|
const start = Math.max(0, byteOffset - contextBytes);
|
|
258
262
|
const end = Math.min(this.fileSizeValue, byteOffset + contextBytes);
|
|
259
263
|
|
|
260
|
-
//
|
|
264
|
+
// Context around a line can only be read unfiltered, so the query is dropped
|
|
265
|
+
// here on purpose - and the mode switch is now visible on the toolbar.
|
|
261
266
|
this.filterInputTarget.value = '';
|
|
262
|
-
this
|
|
263
|
-
this.liveModeTarget.checked = false;
|
|
267
|
+
this.#setMode('static');
|
|
264
268
|
|
|
265
269
|
// Update URL with byte_offset and range
|
|
266
270
|
this.#updateUrlParam('byte_offset', byteOffset);
|
|
@@ -292,16 +296,7 @@ export default class LogStreamerController extends Controller {
|
|
|
292
296
|
}
|
|
293
297
|
|
|
294
298
|
#scrollVerticallyToCenter(element) {
|
|
295
|
-
|
|
296
|
-
let row = element;
|
|
297
|
-
while (row.parentElement && !row.parentElement.classList.contains('clusterize-content')) {
|
|
298
|
-
row = row.parentElement;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
if (!row) return;
|
|
302
|
-
|
|
303
|
-
// Scroll into view first to ensure element is rendered
|
|
304
|
-
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
299
|
+
this.#rowElement(element).scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
305
300
|
}
|
|
306
301
|
|
|
307
302
|
#clearHighlighting() {
|
|
@@ -377,26 +372,21 @@ export default class LogStreamerController extends Controller {
|
|
|
377
372
|
this.startSliderTarget.value = start;
|
|
378
373
|
this.endSliderTarget.value = end;
|
|
379
374
|
|
|
380
|
-
// Trigger range-slider controller to update visuals
|
|
381
375
|
if (this.hasRangeSliderContainerTarget) {
|
|
382
|
-
this.rangeSliderContainerTarget.dispatchEvent(new
|
|
376
|
+
this.rangeSliderContainerTarget.dispatchEvent(new CustomEvent('range-slider:refresh'));
|
|
383
377
|
}
|
|
384
|
-
|
|
385
|
-
// Also update visuals for log-streamer
|
|
386
|
-
this.updateRangeVisuals();
|
|
387
378
|
}
|
|
388
379
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
380
|
+
// The slider only exists in search mode, and it only ever changes the range.
|
|
381
|
+
// It used to flip the mode as a side effect, which is how people ended up in
|
|
382
|
+
// live mode without having asked for it.
|
|
383
|
+
handleRangeUpdate(event) {
|
|
384
|
+
const { start, end } = event?.detail ?? this.#currentRange();
|
|
385
|
+
const isDefaultRange = this.#isFullRange(start, end);
|
|
393
386
|
|
|
394
|
-
this.liveModeTarget.checked = isDefaultRange;
|
|
395
|
-
this.modeValue = isDefaultRange ? 'live' : 'static';
|
|
396
387
|
this.#updateUrlParams({
|
|
397
388
|
start_position: isDefaultRange ? null : start,
|
|
398
|
-
end_position: isDefaultRange ? null : end
|
|
399
|
-
mode: isDefaultRange ? null : 'static'
|
|
389
|
+
end_position: isDefaultRange ? null : end
|
|
400
390
|
});
|
|
401
391
|
|
|
402
392
|
// Clear byte_offset and highlighting if it falls outside the new range
|
|
@@ -412,7 +402,7 @@ export default class LogStreamerController extends Controller {
|
|
|
412
402
|
|
|
413
403
|
resetRange() {
|
|
414
404
|
this.#setRange(0, this.fileSizeValue);
|
|
415
|
-
this
|
|
405
|
+
this.handleRangeUpdate();
|
|
416
406
|
}
|
|
417
407
|
|
|
418
408
|
#restoreStateFromUrl() {
|
|
@@ -450,16 +440,11 @@ export default class LogStreamerController extends Controller {
|
|
|
450
440
|
const end = endParam ? parseInt(endParam) : this.fileSizeValue;
|
|
451
441
|
this.#setRange(start, end);
|
|
452
442
|
|
|
453
|
-
// Calculate mode: check mode param, default to live
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
} else {
|
|
459
|
-
// Default to live mode
|
|
460
|
-
this.modeValue = 'live';
|
|
461
|
-
this.liveModeTarget.checked = true;
|
|
462
|
-
}
|
|
443
|
+
// Calculate mode: check mode param, default to live unless live is disabled
|
|
444
|
+
const wantsStatic = params.get('mode') === 'static' || !this.liveEnabledValue;
|
|
445
|
+
this.modeValue = wantsStatic ? 'static' : 'live';
|
|
446
|
+
this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
|
|
447
|
+
this.#syncModeControls();
|
|
463
448
|
}
|
|
464
449
|
|
|
465
450
|
/**
|
|
@@ -497,7 +482,40 @@ export default class LogStreamerController extends Controller {
|
|
|
497
482
|
/**
|
|
498
483
|
* Handle successful connection
|
|
499
484
|
*/
|
|
485
|
+
// An empty query matches every line, so running it would stream the whole file
|
|
486
|
+
// back. Nothing is searched until something is typed.
|
|
487
|
+
#isIdleSearch() {
|
|
488
|
+
if (this.isLiveMode()) return false;
|
|
489
|
+
if (this.filterInputTarget.value.trim() !== "") return false;
|
|
490
|
+
|
|
491
|
+
// A bounded range - the slider, or the window "show around this line" opens -
|
|
492
|
+
// is a read of a known slice, not an unbounded scan, so it still runs.
|
|
493
|
+
const start = parseInt(this.startSliderTarget.value);
|
|
494
|
+
const end = parseInt(this.endSliderTarget.value);
|
|
495
|
+
return this.#isFullRange(start, end);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
#syncSearchPlaceholder() {
|
|
499
|
+
if (!this.hasSearchPlaceholderTarget) return;
|
|
500
|
+
this.searchPlaceholderTarget.hidden = !this.#isIdleSearch();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#setConnectionState(status) {
|
|
504
|
+
this.#updateWebsocketStatus(status);
|
|
505
|
+
this.updateStopButtonVisibility();
|
|
506
|
+
}
|
|
507
|
+
|
|
500
508
|
#handleConnected() {
|
|
509
|
+
this.#setConnectionState('connected');
|
|
510
|
+
|
|
511
|
+
if (this.#isIdleSearch()) {
|
|
512
|
+
this.isSearchFinished = true;
|
|
513
|
+
this.#hideMessage();
|
|
514
|
+
this.#updateResultsDisplay();
|
|
515
|
+
this.updateStopButtonVisibility();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
501
519
|
const data = {
|
|
502
520
|
file_path: this.filePathValue,
|
|
503
521
|
filter: this.filterInputTarget.value,
|
|
@@ -509,7 +527,7 @@ export default class LogStreamerController extends Controller {
|
|
|
509
527
|
const startSliderValue = parseInt(this.startSliderTarget.value);
|
|
510
528
|
const endSliderValue = parseInt(this.endSliderTarget.value);
|
|
511
529
|
|
|
512
|
-
if (startSliderValue
|
|
530
|
+
if (!this.#isFullRange(startSliderValue, endSliderValue)) {
|
|
513
531
|
data.start_position = startSliderValue;
|
|
514
532
|
data.end_position = endSliderValue;
|
|
515
533
|
} else if (this.modeValue === 'static' && this.endPositionValue > 0) {
|
|
@@ -520,24 +538,16 @@ export default class LogStreamerController extends Controller {
|
|
|
520
538
|
|
|
521
539
|
this.subscription.perform('initialize_watcher', data);
|
|
522
540
|
|
|
523
|
-
this.element.classList.add("log-streamer--connected");
|
|
524
|
-
this.element.classList.remove("log-streamer--disconnected", "log-streamer--rejected");
|
|
525
|
-
this.#updateWebsocketStatus('connected');
|
|
526
541
|
this.updateStopButtonVisibility();
|
|
542
|
+
this.#syncSearchPlaceholder();
|
|
527
543
|
}
|
|
528
544
|
|
|
529
545
|
#handleDisconnected() {
|
|
530
|
-
this
|
|
531
|
-
this.element.classList.remove("log-streamer--connected");
|
|
532
|
-
this.#updateWebsocketStatus('disconnected');
|
|
533
|
-
this.updateStopButtonVisibility();
|
|
546
|
+
this.#setConnectionState('disconnected');
|
|
534
547
|
}
|
|
535
548
|
|
|
536
549
|
#handleRejected() {
|
|
537
|
-
this
|
|
538
|
-
this.element.classList.remove("log-streamer--connected", "log-streamer--disconnected");
|
|
539
|
-
this.#updateWebsocketStatus('rejected');
|
|
540
|
-
this.updateStopButtonVisibility();
|
|
550
|
+
this.#setConnectionState('rejected');
|
|
541
551
|
}
|
|
542
552
|
|
|
543
553
|
#handleLogLines(lines) {
|
|
@@ -581,9 +591,11 @@ export default class LogStreamerController extends Controller {
|
|
|
581
591
|
// logLine is a JSON object: {content, byte_offset, show_expand_button}
|
|
582
592
|
const { content, byte_offset, show_expand_button } = logLine;
|
|
583
593
|
|
|
584
|
-
|
|
594
|
+
const hasOffset = byte_offset != null;
|
|
595
|
+
|
|
596
|
+
if (hasOffset && show_expand_button) {
|
|
585
597
|
return `<div style="display: flex; align-items: center;"><button class="onlylogs-expand-btn" data-byte-offset="${byte_offset}" data-action="click->log-streamer#handleExpandClick">+</button><pre data-byte-offset="${byte_offset}">${content}</pre></div>`;
|
|
586
|
-
} else if (
|
|
598
|
+
} else if (hasOffset) {
|
|
587
599
|
return `<pre data-byte-offset="${byte_offset}">${content}</pre>`;
|
|
588
600
|
} else {
|
|
589
601
|
return `<pre>${content}</pre>`;
|
|
@@ -591,18 +603,14 @@ export default class LogStreamerController extends Controller {
|
|
|
591
603
|
}
|
|
592
604
|
|
|
593
605
|
#handleMessage(message) {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
this.messageTarget.innerHTML = "";
|
|
597
|
-
} else {
|
|
598
|
-
const loadingIcon = message.endsWith('...') ? '<span class="onlylogs-spin-animation">⟳</span>' : '';
|
|
599
|
-
this.messageTarget.innerHTML = loadingIcon + message;
|
|
600
|
-
}
|
|
606
|
+
const loadingIcon = message.endsWith('...') ? '<span class="onlylogs-spin-animation">⟳</span>' : '';
|
|
607
|
+
this.messageTarget.innerHTML = message ? loadingIcon + message : '';
|
|
601
608
|
}
|
|
602
609
|
|
|
603
610
|
#handleFinish(message) {
|
|
604
611
|
this.messageTarget.innerHTML = message;
|
|
605
612
|
this.isSearchFinished = true;
|
|
613
|
+
this.#updateResultsDisplay();
|
|
606
614
|
this.updateStopButtonVisibility();
|
|
607
615
|
}
|
|
608
616
|
|
|
@@ -624,9 +632,40 @@ export default class LogStreamerController extends Controller {
|
|
|
624
632
|
this.messageTarget.innerHTML = '';
|
|
625
633
|
}
|
|
626
634
|
|
|
635
|
+
// Never silent: a live tail with a query that has not matched yet has to look
|
|
636
|
+
// different from a search that came back empty, or the two are indistinguishable.
|
|
627
637
|
#updateResultsDisplay() {
|
|
628
|
-
const
|
|
629
|
-
|
|
638
|
+
const count = this.#formatNumber(this.clusterize.getRowsAmount());
|
|
639
|
+
const hasFilter = this.filterInputTarget.value.trim() !== '';
|
|
640
|
+
const results = this.resultsTarget;
|
|
641
|
+
|
|
642
|
+
results.classList.remove('results-text--watching', 'results-text--live', 'results-text--found');
|
|
643
|
+
|
|
644
|
+
this.#syncSearchPlaceholder();
|
|
645
|
+
|
|
646
|
+
if (this.#isIdleSearch()) {
|
|
647
|
+
results.textContent = 'No query';
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (!this.isLiveMode()) {
|
|
652
|
+
results.textContent = `Results: ${count}`;
|
|
653
|
+
if (this.isSearchFinished) results.classList.add('results-text--found');
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (hasFilter) {
|
|
658
|
+
results.classList.add('results-text--watching');
|
|
659
|
+
results.textContent = `⏳ watching · ${count} new since ${this.#liveFilterSinceLabel()}`;
|
|
660
|
+
} else {
|
|
661
|
+
results.classList.add('results-text--live');
|
|
662
|
+
results.textContent = `${count} lines · live`;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
#liveFilterSinceLabel() {
|
|
667
|
+
const since = this.#liveFilterStartedAt || new Date();
|
|
668
|
+
return since.toTimeString().slice(0, 5);
|
|
630
669
|
}
|
|
631
670
|
|
|
632
671
|
#formatNumber(number) {
|
|
@@ -660,15 +699,6 @@ export default class LogStreamerController extends Controller {
|
|
|
660
699
|
}
|
|
661
700
|
}
|
|
662
701
|
|
|
663
|
-
getStatus() {
|
|
664
|
-
return {
|
|
665
|
-
isRunning: this.isRunning,
|
|
666
|
-
filePath: this.filePathValue,
|
|
667
|
-
lineCount: this.clusterize.getRowsAmount(),
|
|
668
|
-
connected: this.subscription && this.subscription.identifier
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
|
|
672
702
|
#initializeClusterize() {
|
|
673
703
|
this.clusterize = new window.Clusterize({
|
|
674
704
|
scrollId: 'scrollArea',
|
|
@@ -682,9 +712,6 @@ export default class LogStreamerController extends Controller {
|
|
|
682
712
|
no_data_class: 'clusterize-no-data',
|
|
683
713
|
keep_parity: true,
|
|
684
714
|
callbacks: {
|
|
685
|
-
clusterWillChange: () => {
|
|
686
|
-
// Optional: handle cluster change
|
|
687
|
-
},
|
|
688
715
|
clusterChanged: () => {
|
|
689
716
|
// Re-apply highlighting when cluster changes (for virtual scrolling).
|
|
690
717
|
// The byte_offset URL param is the highlight anchor for an explore window.
|
|
@@ -698,9 +725,6 @@ export default class LogStreamerController extends Controller {
|
|
|
698
725
|
}
|
|
699
726
|
}
|
|
700
727
|
}
|
|
701
|
-
},
|
|
702
|
-
scrollingProgress: (progress) => {
|
|
703
|
-
// Optional: handle scrolling progress
|
|
704
728
|
}
|
|
705
729
|
}
|
|
706
730
|
});
|
|
@@ -745,67 +769,17 @@ export default class LogStreamerController extends Controller {
|
|
|
745
769
|
}, 1000);
|
|
746
770
|
}
|
|
747
771
|
|
|
748
|
-
//
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
const
|
|
753
|
-
const step = Number(this.startSliderTarget.step);
|
|
754
|
-
|
|
755
|
-
// Snap to 100% if close to max (within 2% or one step)
|
|
756
|
-
const threshold = Math.max(sliderMax * 0.02, step);
|
|
757
|
-
if (end > sliderMax - threshold) {
|
|
758
|
-
end = sliderMax;
|
|
759
|
-
this.endSliderTarget.value = end;
|
|
760
|
-
}
|
|
761
|
-
if (start > sliderMax - threshold) {
|
|
762
|
-
start = sliderMax;
|
|
763
|
-
this.startSliderTarget.value = start;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
// Enforce start <= end
|
|
767
|
-
if (start > end) {
|
|
768
|
-
[start, end] = [end, start];
|
|
769
|
-
this.startSliderTarget.value = start;
|
|
770
|
-
this.endSliderTarget.value = end;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
this.#updateRangeDisplay(start, end);
|
|
774
|
-
|
|
775
|
-
if (event?.type === 'change') {
|
|
776
|
-
const step = this.#stepForRange(start, end);
|
|
777
|
-
if (step !== this.lastRangeStep) {
|
|
778
|
-
this.lastRangeStep = step;
|
|
779
|
-
this.startSliderTarget.step = step;
|
|
780
|
-
this.endSliderTarget.step = step;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
this.rangeSliderContainerTarget.dispatchEvent(new CustomEvent("range:update", { detail: { start, end } }));
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
#stepForRange(start, end) {
|
|
788
|
-
const selectedBytes = Math.max(end - start, 1);
|
|
789
|
-
const step = 10 ** Math.ceil(Math.log10(selectedBytes / 200));
|
|
790
|
-
|
|
791
|
-
return Math.max(1, step);
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
#updateRangeDisplay(start, end) {
|
|
795
|
-
const sliderMax = Number(this.startSliderTarget.max);
|
|
796
|
-
|
|
797
|
-
this.rangeSliderContainerTarget.style.setProperty("--range-start-percent", `${(start / sliderMax) * 100}%`);
|
|
798
|
-
this.rangeSliderContainerTarget.style.setProperty("--range-end-percent", `${(end / sliderMax) * 100}%`);
|
|
772
|
+
// A range input snaps its value to `step` from `min`, so the end thumb can never
|
|
773
|
+
// land exactly on a file size that is not a multiple of the step. Anything within
|
|
774
|
+
// one step of the end is the whole file, not a range a few hundred bytes short.
|
|
775
|
+
#isFullRange(start, end) {
|
|
776
|
+
const step = Number(this.endSliderTarget.step) || 1;
|
|
799
777
|
|
|
800
|
-
this.
|
|
801
|
-
this.endOutputTarget.textContent = `${this.#formatPercent(end)}%`;
|
|
778
|
+
return start <= 0 && end >= this.fileSizeValue - step;
|
|
802
779
|
}
|
|
803
780
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
if (value <= 0) return '0.0';
|
|
808
|
-
const percent = (value / this.fileSizeValue) * 100;
|
|
809
|
-
return percent.toFixed(1);
|
|
781
|
+
// The live values of the two handles.
|
|
782
|
+
#currentRange() {
|
|
783
|
+
return { start: Number(this.startSliderTarget.value), end: Number(this.endSliderTarget.value) };
|
|
810
784
|
}
|
|
811
785
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
|
|
3
|
+
// Owns the presentation of the double range slider: clamping the two handles,
|
|
4
|
+
// painting the selected span, formatting the byte readout and widening the step
|
|
5
|
+
// as the selected range grows.
|
|
6
|
+
//
|
|
7
|
+
// It holds no state of its own - the inputs are the state. Whoever sets those
|
|
8
|
+
// values programmatically fires `range-slider:refresh` to have them repainted,
|
|
9
|
+
// and gets `range-slider:change` back when a user finishes dragging.
|
|
10
|
+
export default class RangeSliderController extends Controller {
|
|
11
|
+
static targets = ["startInput", "endInput", "startOutput", "endOutput"];
|
|
12
|
+
|
|
13
|
+
connect() {
|
|
14
|
+
this.lastStep = null;
|
|
15
|
+
this.refresh();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Bound to input/change on both handles. Only `change` - the end of a drag -
|
|
19
|
+
// is worth re-reading the file for.
|
|
20
|
+
updateVisuals(event) {
|
|
21
|
+
const { start, end } = this.#clampedValues();
|
|
22
|
+
|
|
23
|
+
this.#paint(start, end);
|
|
24
|
+
|
|
25
|
+
if (event?.type === "change") {
|
|
26
|
+
this.#growStep(start, end);
|
|
27
|
+
this.dispatch("change", { detail: { start, end } });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The values were set from outside; repaint without reporting a change.
|
|
32
|
+
refresh() {
|
|
33
|
+
const { start, end } = this.#clampedValues();
|
|
34
|
+
this.#paint(start, end);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Reads the handles, snapping to the ends and keeping start <= end, and
|
|
38
|
+
// writes back whatever it had to correct.
|
|
39
|
+
#clampedValues() {
|
|
40
|
+
let start = Number(this.startInputTarget.value);
|
|
41
|
+
let end = Number(this.endInputTarget.value);
|
|
42
|
+
|
|
43
|
+
const sliderMax = Number(this.startInputTarget.max);
|
|
44
|
+
const step = Number(this.startInputTarget.step);
|
|
45
|
+
|
|
46
|
+
// Snap to 100% if close to max (within 2% or one step)
|
|
47
|
+
const threshold = Math.max(sliderMax * 0.02, step);
|
|
48
|
+
if (end > sliderMax - threshold) end = sliderMax;
|
|
49
|
+
if (start > sliderMax - threshold) start = sliderMax;
|
|
50
|
+
|
|
51
|
+
if (start > end) [start, end] = [end, start];
|
|
52
|
+
|
|
53
|
+
this.startInputTarget.value = start;
|
|
54
|
+
this.endInputTarget.value = end;
|
|
55
|
+
|
|
56
|
+
return { start, end };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#paint(start, end) {
|
|
60
|
+
const sliderMax = Number(this.startInputTarget.max);
|
|
61
|
+
|
|
62
|
+
this.element.style.setProperty("--range-start-percent", `${(start / sliderMax) * 100}%`);
|
|
63
|
+
this.element.style.setProperty("--range-end-percent", `${(end / sliderMax) * 100}%`);
|
|
64
|
+
|
|
65
|
+
this.startOutputTarget.textContent = this.#formatBytes(start);
|
|
66
|
+
this.endOutputTarget.textContent = this.#formatBytes(end);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A narrow selection wants byte-level precision; a wide one would take
|
|
70
|
+
// thousands of steps to cross at that resolution.
|
|
71
|
+
#growStep(start, end) {
|
|
72
|
+
const selectedBytes = Math.max(end - start, 1);
|
|
73
|
+
const step = Math.max(1, 10 ** Math.ceil(Math.log10(selectedBytes / 200)));
|
|
74
|
+
|
|
75
|
+
if (step === this.lastStep) return;
|
|
76
|
+
|
|
77
|
+
this.lastStep = step;
|
|
78
|
+
this.startInputTarget.step = step;
|
|
79
|
+
this.endInputTarget.step = step;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#formatBytes(value) {
|
|
83
|
+
const units = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
|
84
|
+
let size = Math.max(0, value);
|
|
85
|
+
let unit = 0;
|
|
86
|
+
|
|
87
|
+
while (size >= 1024 && unit < units.length - 1) {
|
|
88
|
+
size /= 1024;
|
|
89
|
+
unit += 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rounded = unit === 0 ? size : Number(size.toFixed(1));
|
|
93
|
+
|
|
94
|
+
return `${rounded} ${units[unit]}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -90,6 +90,7 @@ export default class TextSelectionController extends Controller {
|
|
|
90
90
|
|
|
91
91
|
this.filterInputTarget.value = this.selectedText
|
|
92
92
|
this.filterInputTarget.dispatchEvent(new Event('input', { bubbles: true }))
|
|
93
|
+
this.dispatch("search")
|
|
93
94
|
this.hideButton()
|
|
94
95
|
window.getSelection().removeAllRanges()
|
|
95
96
|
}
|