onlylogs 0.9.0 → 0.10.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 +11 -1
- data/app/channels/onlylogs/logs_channel.rb +82 -70
- data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +15 -11
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +170 -202
- 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/grep.rb +14 -6
- data/app/views/onlylogs/logs/index.html.erb +25 -5
- data/app/views/onlylogs/shared/_log_container.html.erb +71 -35
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +246 -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,7 +5,6 @@ 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' },
|
|
11
10
|
regexpMode: { type: Boolean, default: false },
|
|
@@ -14,7 +13,10 @@ export default class LogStreamerController extends Controller {
|
|
|
14
13
|
endPosition: { type: Number, default: 0 }
|
|
15
14
|
};
|
|
16
15
|
|
|
17
|
-
static targets = ["logLines", "filterInput", "results", "
|
|
16
|
+
static targets = ["logLines", "filterInput", "results", "liveButton", "searchButton", "searchWholeFileButton", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll", "rangeSliderContainer", "startSlider", "endSlider", "searchPlaceholder"];
|
|
17
|
+
|
|
18
|
+
// When the current live query started, so "0 matches" can say since when.
|
|
19
|
+
#liveFilterStartedAt = null;
|
|
18
20
|
|
|
19
21
|
connect() {
|
|
20
22
|
this.consumer = createConsumer();
|
|
@@ -23,7 +25,6 @@ export default class LogStreamerController extends Controller {
|
|
|
23
25
|
this.isRunning = false;
|
|
24
26
|
this.reconnectTimeout = null;
|
|
25
27
|
this.isSearchFinished = true;
|
|
26
|
-
this.lastRangeStep = null;
|
|
27
28
|
this.historyUpdateTimeout = null;
|
|
28
29
|
this.contextLineHighlighted = false;
|
|
29
30
|
|
|
@@ -33,13 +34,6 @@ export default class LogStreamerController extends Controller {
|
|
|
33
34
|
|
|
34
35
|
this.#updateWebsocketStatus('disconnected');
|
|
35
36
|
|
|
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
37
|
// Restore state from URL params if present
|
|
44
38
|
this.#restoreStateFromUrl();
|
|
45
39
|
|
|
@@ -111,17 +105,13 @@ export default class LogStreamerController extends Controller {
|
|
|
111
105
|
}
|
|
112
106
|
|
|
113
107
|
pauseForSelection() {
|
|
114
|
-
// Triggered by TextSelectionController#handleMouseDown via text-selection:start event
|
|
115
|
-
//
|
|
108
|
+
// Triggered by TextSelectionController#handleMouseDown via text-selection:start event.
|
|
109
|
+
// Stopping autoscroll is enough to keep the text still under the cursor; the mode
|
|
110
|
+
// is the user's to change, so selecting text must never switch it.
|
|
116
111
|
if (this.autoScrollValue) {
|
|
117
112
|
this.autoScrollValue = false;
|
|
118
113
|
this.autoscrollTarget.checked = false;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (this.isLiveMode()) {
|
|
122
|
-
this.liveModeTarget.checked = false;
|
|
123
|
-
this.modeValue = 'static';
|
|
124
|
-
this.stop();
|
|
114
|
+
this.#updateUrlParam('autoscroll', 'false');
|
|
125
115
|
}
|
|
126
116
|
}
|
|
127
117
|
|
|
@@ -134,32 +124,34 @@ export default class LogStreamerController extends Controller {
|
|
|
134
124
|
}
|
|
135
125
|
}
|
|
136
126
|
|
|
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
|
-
}
|
|
127
|
+
switchToLive() {
|
|
128
|
+
if (this.isLiveMode()) return;
|
|
129
|
+
|
|
130
|
+
this.#clearHighlighting();
|
|
131
|
+
this.#setMode('live');
|
|
132
|
+
this.clear();
|
|
133
|
+
this.#setRange(0, this.fileSizeValue);
|
|
134
|
+
this.#updateUrlParams({ start_position: null, end_position: null, byte_offset: null });
|
|
135
|
+
this.reconnectWithNewMode();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
switchToSearch() {
|
|
139
|
+
if (!this.isLiveMode()) return;
|
|
140
|
+
|
|
141
|
+
this.#setMode('static');
|
|
142
|
+
this.reconnectWithNewMode();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Bound only to the "Search whole file" button, which is shown while tailing.
|
|
146
|
+
// Leaves live mode: runs what is already typed against the file instead of
|
|
147
|
+
// waiting for a matching line to arrive.
|
|
148
|
+
searchWholeFile() {
|
|
149
|
+
this.#clearHighlighting();
|
|
150
|
+
this.#setMode('static');
|
|
151
|
+
this.#setRange(0, this.fileSizeValue);
|
|
152
|
+
this.#updateUrlParams({ start_position: null, end_position: null, byte_offset: null });
|
|
153
|
+
this.reconnectWithNewMode();
|
|
154
|
+
this.filterInputTarget.focus();
|
|
163
155
|
}
|
|
164
156
|
|
|
165
157
|
applyFilter() {
|
|
@@ -172,14 +164,39 @@ export default class LogStreamerController extends Controller {
|
|
|
172
164
|
// Update visual state
|
|
173
165
|
this.updateStopButtonVisibility();
|
|
174
166
|
this.#updateUrlParam('filter', filterValue || null);
|
|
175
|
-
this.#
|
|
167
|
+
this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
|
|
168
|
+
this.#updateResultsDisplay();
|
|
176
169
|
|
|
177
170
|
// Use the global debounced reconnection (300ms delay)
|
|
178
171
|
this.reconnectWithNewMode();
|
|
179
172
|
}
|
|
180
173
|
|
|
181
174
|
isLiveMode() {
|
|
182
|
-
return this.
|
|
175
|
+
return this.modeValue === 'live';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The only writer of modeValue. Everything that changes the mode goes through
|
|
179
|
+
// here so the switch, the URL and the toolbar layout can never disagree.
|
|
180
|
+
#setMode(mode) {
|
|
181
|
+
this.modeValue = mode;
|
|
182
|
+
this.#updateUrlParam('mode', mode === 'live' ? null : 'static');
|
|
183
|
+
this.#liveFilterStartedAt = mode === 'live' ? new Date() : null;
|
|
184
|
+
this.#syncModeControls();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#syncModeControls() {
|
|
188
|
+
const live = this.isLiveMode();
|
|
189
|
+
this.#syncSearchPlaceholder();
|
|
190
|
+
|
|
191
|
+
if (this.hasLiveButtonTarget) {
|
|
192
|
+
this.liveButtonTarget.setAttribute('aria-pressed', live);
|
|
193
|
+
}
|
|
194
|
+
if (this.hasSearchButtonTarget) {
|
|
195
|
+
this.searchButtonTarget.setAttribute('aria-pressed', !live);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.filterInputTarget.placeholder = live ? 'follow lines matching…' : 'search the whole file…';
|
|
199
|
+
this.#updateResultsDisplay();
|
|
183
200
|
}
|
|
184
201
|
|
|
185
202
|
scroll() {
|
|
@@ -210,34 +227,16 @@ export default class LogStreamerController extends Controller {
|
|
|
210
227
|
}, 600);
|
|
211
228
|
}
|
|
212
229
|
|
|
230
|
+
// Clears the text only. The mode and the range belong to their own controls,
|
|
231
|
+
// so an × on a text field must not quietly reach over and change them.
|
|
213
232
|
clearFilter() {
|
|
214
|
-
// Clear filter and explore window to go back to pure live mode
|
|
215
233
|
this.filterInputTarget.value = '';
|
|
216
|
-
this.modeValue = 'live';
|
|
217
|
-
this.startPositionValue = 0;
|
|
218
|
-
this.endPositionValue = this.fileSizeValue;
|
|
219
|
-
|
|
220
|
-
// Clear highlighting
|
|
221
234
|
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
235
|
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
|
|
236
|
+
this.#updateUrlParams({ filter: null, byte_offset: null });
|
|
237
|
+
this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
|
|
240
238
|
this.reconnectWithNewMode();
|
|
239
|
+
this.filterInputTarget.focus();
|
|
241
240
|
}
|
|
242
241
|
|
|
243
242
|
stopSearch() {
|
|
@@ -257,10 +256,10 @@ export default class LogStreamerController extends Controller {
|
|
|
257
256
|
const start = Math.max(0, byteOffset - contextBytes);
|
|
258
257
|
const end = Math.min(this.fileSizeValue, byteOffset + contextBytes);
|
|
259
258
|
|
|
260
|
-
//
|
|
259
|
+
// Context around a line can only be read unfiltered, so the query is dropped
|
|
260
|
+
// here on purpose - and the mode switch is now visible on the toolbar.
|
|
261
261
|
this.filterInputTarget.value = '';
|
|
262
|
-
this
|
|
263
|
-
this.liveModeTarget.checked = false;
|
|
262
|
+
this.#setMode('static');
|
|
264
263
|
|
|
265
264
|
// Update URL with byte_offset and range
|
|
266
265
|
this.#updateUrlParam('byte_offset', byteOffset);
|
|
@@ -292,16 +291,7 @@ export default class LogStreamerController extends Controller {
|
|
|
292
291
|
}
|
|
293
292
|
|
|
294
293
|
#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' });
|
|
294
|
+
this.#rowElement(element).scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
305
295
|
}
|
|
306
296
|
|
|
307
297
|
#clearHighlighting() {
|
|
@@ -377,26 +367,21 @@ export default class LogStreamerController extends Controller {
|
|
|
377
367
|
this.startSliderTarget.value = start;
|
|
378
368
|
this.endSliderTarget.value = end;
|
|
379
369
|
|
|
380
|
-
// Trigger range-slider controller to update visuals
|
|
381
370
|
if (this.hasRangeSliderContainerTarget) {
|
|
382
|
-
this.rangeSliderContainerTarget.dispatchEvent(new
|
|
371
|
+
this.rangeSliderContainerTarget.dispatchEvent(new CustomEvent('range-slider:refresh'));
|
|
383
372
|
}
|
|
384
|
-
|
|
385
|
-
// Also update visuals for log-streamer
|
|
386
|
-
this.updateRangeVisuals();
|
|
387
373
|
}
|
|
388
374
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
375
|
+
// The slider only exists in search mode, and it only ever changes the range.
|
|
376
|
+
// It used to flip the mode as a side effect, which is how people ended up in
|
|
377
|
+
// live mode without having asked for it.
|
|
378
|
+
handleRangeUpdate(event) {
|
|
379
|
+
const { start, end } = event?.detail ?? this.#currentRange();
|
|
380
|
+
const isDefaultRange = this.#isFullRange(start, end);
|
|
393
381
|
|
|
394
|
-
this.liveModeTarget.checked = isDefaultRange;
|
|
395
|
-
this.modeValue = isDefaultRange ? 'live' : 'static';
|
|
396
382
|
this.#updateUrlParams({
|
|
397
383
|
start_position: isDefaultRange ? null : start,
|
|
398
|
-
end_position: isDefaultRange ? null : end
|
|
399
|
-
mode: isDefaultRange ? null : 'static'
|
|
384
|
+
end_position: isDefaultRange ? null : end
|
|
400
385
|
});
|
|
401
386
|
|
|
402
387
|
// Clear byte_offset and highlighting if it falls outside the new range
|
|
@@ -412,7 +397,7 @@ export default class LogStreamerController extends Controller {
|
|
|
412
397
|
|
|
413
398
|
resetRange() {
|
|
414
399
|
this.#setRange(0, this.fileSizeValue);
|
|
415
|
-
this
|
|
400
|
+
this.handleRangeUpdate();
|
|
416
401
|
}
|
|
417
402
|
|
|
418
403
|
#restoreStateFromUrl() {
|
|
@@ -451,15 +436,9 @@ export default class LogStreamerController extends Controller {
|
|
|
451
436
|
this.#setRange(start, end);
|
|
452
437
|
|
|
453
438
|
// Calculate mode: check mode param, default to live
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
this.liveModeTarget.checked = false;
|
|
458
|
-
} else {
|
|
459
|
-
// Default to live mode
|
|
460
|
-
this.modeValue = 'live';
|
|
461
|
-
this.liveModeTarget.checked = true;
|
|
462
|
-
}
|
|
439
|
+
this.modeValue = params.get('mode') === 'static' ? 'static' : 'live';
|
|
440
|
+
this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
|
|
441
|
+
this.#syncModeControls();
|
|
463
442
|
}
|
|
464
443
|
|
|
465
444
|
/**
|
|
@@ -497,7 +476,40 @@ export default class LogStreamerController extends Controller {
|
|
|
497
476
|
/**
|
|
498
477
|
* Handle successful connection
|
|
499
478
|
*/
|
|
479
|
+
// An empty query matches every line, so running it would stream the whole file
|
|
480
|
+
// back. Nothing is searched until something is typed.
|
|
481
|
+
#isIdleSearch() {
|
|
482
|
+
if (this.isLiveMode()) return false;
|
|
483
|
+
if (this.filterInputTarget.value.trim() !== "") return false;
|
|
484
|
+
|
|
485
|
+
// A bounded range - the slider, or the window "show around this line" opens -
|
|
486
|
+
// is a read of a known slice, not an unbounded scan, so it still runs.
|
|
487
|
+
const start = parseInt(this.startSliderTarget.value);
|
|
488
|
+
const end = parseInt(this.endSliderTarget.value);
|
|
489
|
+
return this.#isFullRange(start, end);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#syncSearchPlaceholder() {
|
|
493
|
+
if (!this.hasSearchPlaceholderTarget) return;
|
|
494
|
+
this.searchPlaceholderTarget.hidden = !this.#isIdleSearch();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
#setConnectionState(status) {
|
|
498
|
+
this.#updateWebsocketStatus(status);
|
|
499
|
+
this.updateStopButtonVisibility();
|
|
500
|
+
}
|
|
501
|
+
|
|
500
502
|
#handleConnected() {
|
|
503
|
+
this.#setConnectionState('connected');
|
|
504
|
+
|
|
505
|
+
if (this.#isIdleSearch()) {
|
|
506
|
+
this.isSearchFinished = true;
|
|
507
|
+
this.#hideMessage();
|
|
508
|
+
this.#updateResultsDisplay();
|
|
509
|
+
this.updateStopButtonVisibility();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
501
513
|
const data = {
|
|
502
514
|
file_path: this.filePathValue,
|
|
503
515
|
filter: this.filterInputTarget.value,
|
|
@@ -509,7 +521,7 @@ export default class LogStreamerController extends Controller {
|
|
|
509
521
|
const startSliderValue = parseInt(this.startSliderTarget.value);
|
|
510
522
|
const endSliderValue = parseInt(this.endSliderTarget.value);
|
|
511
523
|
|
|
512
|
-
if (startSliderValue
|
|
524
|
+
if (!this.#isFullRange(startSliderValue, endSliderValue)) {
|
|
513
525
|
data.start_position = startSliderValue;
|
|
514
526
|
data.end_position = endSliderValue;
|
|
515
527
|
} else if (this.modeValue === 'static' && this.endPositionValue > 0) {
|
|
@@ -520,24 +532,16 @@ export default class LogStreamerController extends Controller {
|
|
|
520
532
|
|
|
521
533
|
this.subscription.perform('initialize_watcher', data);
|
|
522
534
|
|
|
523
|
-
this.element.classList.add("log-streamer--connected");
|
|
524
|
-
this.element.classList.remove("log-streamer--disconnected", "log-streamer--rejected");
|
|
525
|
-
this.#updateWebsocketStatus('connected');
|
|
526
535
|
this.updateStopButtonVisibility();
|
|
536
|
+
this.#syncSearchPlaceholder();
|
|
527
537
|
}
|
|
528
538
|
|
|
529
539
|
#handleDisconnected() {
|
|
530
|
-
this
|
|
531
|
-
this.element.classList.remove("log-streamer--connected");
|
|
532
|
-
this.#updateWebsocketStatus('disconnected');
|
|
533
|
-
this.updateStopButtonVisibility();
|
|
540
|
+
this.#setConnectionState('disconnected');
|
|
534
541
|
}
|
|
535
542
|
|
|
536
543
|
#handleRejected() {
|
|
537
|
-
this
|
|
538
|
-
this.element.classList.remove("log-streamer--connected", "log-streamer--disconnected");
|
|
539
|
-
this.#updateWebsocketStatus('rejected');
|
|
540
|
-
this.updateStopButtonVisibility();
|
|
544
|
+
this.#setConnectionState('rejected');
|
|
541
545
|
}
|
|
542
546
|
|
|
543
547
|
#handleLogLines(lines) {
|
|
@@ -581,9 +585,11 @@ export default class LogStreamerController extends Controller {
|
|
|
581
585
|
// logLine is a JSON object: {content, byte_offset, show_expand_button}
|
|
582
586
|
const { content, byte_offset, show_expand_button } = logLine;
|
|
583
587
|
|
|
584
|
-
|
|
588
|
+
const hasOffset = byte_offset != null;
|
|
589
|
+
|
|
590
|
+
if (hasOffset && show_expand_button) {
|
|
585
591
|
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 (
|
|
592
|
+
} else if (hasOffset) {
|
|
587
593
|
return `<pre data-byte-offset="${byte_offset}">${content}</pre>`;
|
|
588
594
|
} else {
|
|
589
595
|
return `<pre>${content}</pre>`;
|
|
@@ -591,18 +597,14 @@ export default class LogStreamerController extends Controller {
|
|
|
591
597
|
}
|
|
592
598
|
|
|
593
599
|
#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
|
-
}
|
|
600
|
+
const loadingIcon = message.endsWith('...') ? '<span class="onlylogs-spin-animation">⟳</span>' : '';
|
|
601
|
+
this.messageTarget.innerHTML = message ? loadingIcon + message : '';
|
|
601
602
|
}
|
|
602
603
|
|
|
603
604
|
#handleFinish(message) {
|
|
604
605
|
this.messageTarget.innerHTML = message;
|
|
605
606
|
this.isSearchFinished = true;
|
|
607
|
+
this.#updateResultsDisplay();
|
|
606
608
|
this.updateStopButtonVisibility();
|
|
607
609
|
}
|
|
608
610
|
|
|
@@ -624,9 +626,40 @@ export default class LogStreamerController extends Controller {
|
|
|
624
626
|
this.messageTarget.innerHTML = '';
|
|
625
627
|
}
|
|
626
628
|
|
|
629
|
+
// Never silent: a live tail with a query that has not matched yet has to look
|
|
630
|
+
// different from a search that came back empty, or the two are indistinguishable.
|
|
627
631
|
#updateResultsDisplay() {
|
|
628
|
-
const
|
|
629
|
-
|
|
632
|
+
const count = this.#formatNumber(this.clusterize.getRowsAmount());
|
|
633
|
+
const hasFilter = this.filterInputTarget.value.trim() !== '';
|
|
634
|
+
const results = this.resultsTarget;
|
|
635
|
+
|
|
636
|
+
results.classList.remove('results-text--watching', 'results-text--live', 'results-text--found');
|
|
637
|
+
|
|
638
|
+
this.#syncSearchPlaceholder();
|
|
639
|
+
|
|
640
|
+
if (this.#isIdleSearch()) {
|
|
641
|
+
results.textContent = 'No query';
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (!this.isLiveMode()) {
|
|
646
|
+
results.textContent = `Results: ${count}`;
|
|
647
|
+
if (this.isSearchFinished) results.classList.add('results-text--found');
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (hasFilter) {
|
|
652
|
+
results.classList.add('results-text--watching');
|
|
653
|
+
results.textContent = `⏳ watching · ${count} new since ${this.#liveFilterSinceLabel()}`;
|
|
654
|
+
} else {
|
|
655
|
+
results.classList.add('results-text--live');
|
|
656
|
+
results.textContent = `${count} lines · live`;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
#liveFilterSinceLabel() {
|
|
661
|
+
const since = this.#liveFilterStartedAt || new Date();
|
|
662
|
+
return since.toTimeString().slice(0, 5);
|
|
630
663
|
}
|
|
631
664
|
|
|
632
665
|
#formatNumber(number) {
|
|
@@ -660,15 +693,6 @@ export default class LogStreamerController extends Controller {
|
|
|
660
693
|
}
|
|
661
694
|
}
|
|
662
695
|
|
|
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
696
|
#initializeClusterize() {
|
|
673
697
|
this.clusterize = new window.Clusterize({
|
|
674
698
|
scrollId: 'scrollArea',
|
|
@@ -682,9 +706,6 @@ export default class LogStreamerController extends Controller {
|
|
|
682
706
|
no_data_class: 'clusterize-no-data',
|
|
683
707
|
keep_parity: true,
|
|
684
708
|
callbacks: {
|
|
685
|
-
clusterWillChange: () => {
|
|
686
|
-
// Optional: handle cluster change
|
|
687
|
-
},
|
|
688
709
|
clusterChanged: () => {
|
|
689
710
|
// Re-apply highlighting when cluster changes (for virtual scrolling).
|
|
690
711
|
// The byte_offset URL param is the highlight anchor for an explore window.
|
|
@@ -698,9 +719,6 @@ export default class LogStreamerController extends Controller {
|
|
|
698
719
|
}
|
|
699
720
|
}
|
|
700
721
|
}
|
|
701
|
-
},
|
|
702
|
-
scrollingProgress: (progress) => {
|
|
703
|
-
// Optional: handle scrolling progress
|
|
704
722
|
}
|
|
705
723
|
}
|
|
706
724
|
});
|
|
@@ -745,67 +763,17 @@ export default class LogStreamerController extends Controller {
|
|
|
745
763
|
}, 1000);
|
|
746
764
|
}
|
|
747
765
|
|
|
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}%`);
|
|
766
|
+
// A range input snaps its value to `step` from `min`, so the end thumb can never
|
|
767
|
+
// land exactly on a file size that is not a multiple of the step. Anything within
|
|
768
|
+
// one step of the end is the whole file, not a range a few hundred bytes short.
|
|
769
|
+
#isFullRange(start, end) {
|
|
770
|
+
const step = Number(this.endSliderTarget.step) || 1;
|
|
799
771
|
|
|
800
|
-
this.
|
|
801
|
-
this.endOutputTarget.textContent = `${this.#formatPercent(end)}%`;
|
|
772
|
+
return start <= 0 && end >= this.fileSizeValue - step;
|
|
802
773
|
}
|
|
803
774
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
if (value <= 0) return '0.0';
|
|
808
|
-
const percent = (value / this.fileSizeValue) * 100;
|
|
809
|
-
return percent.toFixed(1);
|
|
775
|
+
// The live values of the two handles.
|
|
776
|
+
#currentRange() {
|
|
777
|
+
return { start: Number(this.startSliderTarget.value), end: Number(this.endSliderTarget.value) };
|
|
810
778
|
}
|
|
811
779
|
}
|
|
@@ -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
|
}
|