onlylogs 0.10.0 → 0.12.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 +270 -69
- data/app/assets/images/onlylogs/favicon/apple-touch-icon.png +0 -0
- data/app/assets/images/onlylogs/favicon/favicon-96x96.png +0 -0
- data/app/assets/images/onlylogs/favicon/favicon.ico +0 -0
- data/app/assets/images/onlylogs/favicon/favicon.svg +1 -3
- data/app/assets/images/onlylogs/favicon/web-app-manifest-192x192.png +0 -0
- data/app/assets/images/onlylogs/favicon/web-app-manifest-512x512.png +0 -0
- data/app/assets/images/onlylogs/logo.png +0 -0
- data/app/assets/javascripts/onlylogs/clusterize.js +26 -1
- data/app/channels/onlylogs/logs_channel.rb +22 -9
- data/app/controllers/concerns/onlylogs/authentication.rb +26 -0
- data/app/controllers/onlylogs/application_controller.rb +1 -18
- data/app/controllers/onlylogs/queries_controller.rb +70 -0
- data/app/controllers/onlylogs/viewer_controller.rb +7 -0
- data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +131 -21
- data/app/javascript/onlylogs/controllers/log_cursor_controller.js +286 -0
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +52 -6
- data/app/javascript/onlylogs/controllers/match_highlight_controller.js +82 -0
- data/app/javascript/onlylogs/controllers/queries_controller.js +470 -0
- data/app/javascript/onlylogs/controllers/range_slider_controller.js +5 -4
- data/app/javascript/onlylogs/controllers/text_selection_controller.js +136 -15
- data/app/models/onlylogs/file.rb +27 -7
- data/app/models/onlylogs/grep.rb +13 -6
- data/app/models/onlylogs/queries_record.rb +28 -0
- data/app/models/onlylogs/query.rb +29 -0
- data/app/views/onlylogs/logs/index.html.erb +1 -1
- data/app/views/onlylogs/shared/_log_container.html.erb +179 -30
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +731 -45
- data/app/views/onlylogs/shared/_shortcuts_dialog.html.erb +62 -0
- data/bin/super_grep +7 -0
- data/bin/super_ripgrep +5 -0
- data/config/routes.rb +1 -0
- data/lib/onlylogs/configuration.rb +14 -1
- data/lib/onlylogs/http_device.rb +28 -13
- data/lib/onlylogs/version.rb +1 -1
- data/lib/onlylogs.rb +4 -0
- metadata +38 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// A keyboard cursor over the log lines. Clusterize only keeps a window of the rows
|
|
4
|
+
// in the DOM, so the cursor is an index into all rows, repainted whenever the
|
|
5
|
+
// window is re-rendered. Left/right select words of the cursor line as a real text
|
|
6
|
+
// selection, so the "Search" popup of the text-selection controller picks them up.
|
|
7
|
+
export default class LogCursorController extends Controller {
|
|
8
|
+
static targets = ["lines"]
|
|
9
|
+
|
|
10
|
+
connect() {
|
|
11
|
+
this.index = null
|
|
12
|
+
this.#resetWords()
|
|
13
|
+
this.observer = new MutationObserver(() => this.#paint())
|
|
14
|
+
this.observer.observe(this.#content, { childList: true })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
disconnect() {
|
|
18
|
+
this.observer.disconnect()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
focus(event) {
|
|
22
|
+
event?.preventDefault()
|
|
23
|
+
this.#content.focus({ preventScroll: true })
|
|
24
|
+
this.activate()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
activate() {
|
|
28
|
+
if (this.index === null) this.index = this.#defaultIndex()
|
|
29
|
+
this.#paint()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The rows were thrown away (new query, new mode): an index into them means nothing now.
|
|
33
|
+
reset() {
|
|
34
|
+
this.index = null
|
|
35
|
+
this.#resetWords()
|
|
36
|
+
this.#paint()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
placeAt({ detail: { row } }) {
|
|
40
|
+
const index = this.#indexOf(row)
|
|
41
|
+
if (index === null) return
|
|
42
|
+
|
|
43
|
+
this.index = index
|
|
44
|
+
this.#resetWords()
|
|
45
|
+
this.#paint()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pointAt(event) {
|
|
49
|
+
const row = this.#rowFor(event.target)
|
|
50
|
+
if (row) this.placeAt({ detail: { row } })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
navigate(event) {
|
|
54
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return
|
|
55
|
+
|
|
56
|
+
const handled = this.#handleKey(event)
|
|
57
|
+
if (handled !== false) event.preventDefault()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#handleKey(event) {
|
|
61
|
+
switch (event.key) {
|
|
62
|
+
case "ArrowDown": case "j": return this.#moveBy(1)
|
|
63
|
+
case "ArrowUp": case "k": return this.#moveBy(-1)
|
|
64
|
+
case "PageDown": return this.#moveBy(this.#pageSize())
|
|
65
|
+
case "PageUp": return this.#moveBy(-this.#pageSize())
|
|
66
|
+
case "Home": case "g": return this.#moveTo(0)
|
|
67
|
+
case "End": case "G": return this.#moveTo(this.#rowsAmount - 1)
|
|
68
|
+
case "ArrowRight": return this.#selectWord(1, event.shiftKey)
|
|
69
|
+
case "ArrowLeft": return this.#selectWord(-1, event.shiftKey)
|
|
70
|
+
case "Enter": case "o": return this.#open()
|
|
71
|
+
case "y": return this.#copyLine()
|
|
72
|
+
case "Escape": return this.#escape()
|
|
73
|
+
default: return false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#moveBy(delta) {
|
|
78
|
+
if (this.index === null) return this.activate()
|
|
79
|
+
this.#moveTo(this.index + delta)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#moveTo(index) {
|
|
83
|
+
if (this.#rowsAmount === 0) return
|
|
84
|
+
|
|
85
|
+
this.index = Math.min(Math.max(index, 0), this.#rowsAmount - 1)
|
|
86
|
+
this.#resetWords()
|
|
87
|
+
window.getSelection().removeAllRanges()
|
|
88
|
+
this.dispatch("move")
|
|
89
|
+
this.#reveal()
|
|
90
|
+
this.#paint()
|
|
91
|
+
|
|
92
|
+
// Long lines hide their match past the right edge; keep it in view.
|
|
93
|
+
const match = this.#currentRow()?.querySelector("mark.onlylogs-match")
|
|
94
|
+
if (match) this.#revealHorizontally(match)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Enter acts on what is in front of you: the selected text if there is some,
|
|
98
|
+
// otherwise the line itself.
|
|
99
|
+
#open() {
|
|
100
|
+
if (this.#hasSelectionInLines()) {
|
|
101
|
+
this.dispatch("search")
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.#currentRow()?.querySelector(".onlylogs-expand-btn")?.click()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#copyLine() {
|
|
109
|
+
const row = this.#currentRow()
|
|
110
|
+
if (!row) return
|
|
111
|
+
|
|
112
|
+
navigator.clipboard?.writeText(this.#textElement(row).textContent)
|
|
113
|
+
row.classList.remove("onlylogs-line-copied")
|
|
114
|
+
void row.offsetWidth
|
|
115
|
+
row.classList.add("onlylogs-line-copied")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#escape() {
|
|
119
|
+
if (this.#hasSelectionInLines()) {
|
|
120
|
+
window.getSelection().removeAllRanges()
|
|
121
|
+
this.#resetWords()
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.#content.blur()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Words are what a log line is searched by: request ids, paths, status codes.
|
|
129
|
+
// Quotes, brackets and "=" separate them, so `status=500` offers `500` on its own.
|
|
130
|
+
#selectWord(direction, extend) {
|
|
131
|
+
const row = this.#currentRow()
|
|
132
|
+
if (!row) return this.activate()
|
|
133
|
+
|
|
134
|
+
const element = this.#textElement(row)
|
|
135
|
+
const words = [...element.textContent.matchAll(/[^\s"'=,;()[\]{}<>|]+/g)]
|
|
136
|
+
.map(match => [match.index, match.index + match[0].length])
|
|
137
|
+
if (words.length === 0) return
|
|
138
|
+
|
|
139
|
+
const current = this.focusWord ?? (direction > 0 ? -1 : words.length)
|
|
140
|
+
this.focusWord = Math.min(Math.max(current + direction, 0), words.length - 1)
|
|
141
|
+
if (!extend || this.anchorWord === null) this.anchorWord = this.focusWord
|
|
142
|
+
|
|
143
|
+
const first = words[Math.min(this.anchorWord, this.focusWord)]
|
|
144
|
+
const last = words[Math.max(this.anchorWord, this.focusWord)]
|
|
145
|
+
const range = this.#rangeFor(element, first[0], last[1])
|
|
146
|
+
|
|
147
|
+
const selection = window.getSelection()
|
|
148
|
+
selection.removeAllRanges()
|
|
149
|
+
selection.addRange(range)
|
|
150
|
+
this.#revealHorizontally(range)
|
|
151
|
+
this.dispatch("select")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#rangeFor(element, start, end) {
|
|
155
|
+
const range = document.createRange()
|
|
156
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
|
157
|
+
let offset = 0
|
|
158
|
+
|
|
159
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
160
|
+
const length = node.textContent.length
|
|
161
|
+
if (start >= offset && start <= offset + length) range.setStart(node, start - offset)
|
|
162
|
+
if (end >= offset && end <= offset + length) {
|
|
163
|
+
range.setEnd(node, end - offset)
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
offset += length
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return range
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#resetWords() {
|
|
173
|
+
this.anchorWord = null
|
|
174
|
+
this.focusWord = null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#hasSelectionInLines() {
|
|
178
|
+
const selection = window.getSelection()
|
|
179
|
+
return selection.toString().trim() !== "" && this.linesTarget.contains(selection.anchorNode)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Keeps two lines of context around the cursor, like an editor does.
|
|
183
|
+
#reveal() {
|
|
184
|
+
const lines = this.linesTarget
|
|
185
|
+
const row = this.#currentRow()
|
|
186
|
+
|
|
187
|
+
if (!row) {
|
|
188
|
+
lines.scrollTop = this.index * this.#rowHeight() - lines.clientHeight / 2
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const rowRect = row.getBoundingClientRect()
|
|
193
|
+
const top = lines.getBoundingClientRect().top
|
|
194
|
+
const margin = rowRect.height * 2
|
|
195
|
+
|
|
196
|
+
if (rowRect.top < top + margin) {
|
|
197
|
+
lines.scrollTop -= top + margin - rowRect.top
|
|
198
|
+
} else if (rowRect.bottom > top + lines.clientHeight - margin) {
|
|
199
|
+
lines.scrollTop += rowRect.bottom - (top + lines.clientHeight - margin)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#revealHorizontally(range) {
|
|
204
|
+
const lines = this.linesTarget
|
|
205
|
+
const rect = range.getBoundingClientRect()
|
|
206
|
+
const left = lines.getBoundingClientRect().left
|
|
207
|
+
|
|
208
|
+
if (rect.left < left) {
|
|
209
|
+
lines.scrollLeft -= left - rect.left + 20
|
|
210
|
+
} else if (rect.right > left + lines.clientWidth) {
|
|
211
|
+
lines.scrollLeft += rect.right - (left + lines.clientWidth) + 20
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#paint() {
|
|
216
|
+
this.#content.querySelector(".onlylogs-cursor-line")?.classList.remove("onlylogs-cursor-line")
|
|
217
|
+
if (this.index === null) return
|
|
218
|
+
|
|
219
|
+
if (this.index >= this.#rowsAmount) {
|
|
220
|
+
this.index = null
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.#currentRow()?.classList.add("onlylogs-cursor-line")
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Following a live tail you are looking at its end; anywhere else at the top of the pane.
|
|
228
|
+
#defaultIndex() {
|
|
229
|
+
if (this.#rowsAmount === 0) return null
|
|
230
|
+
|
|
231
|
+
const lines = this.linesTarget
|
|
232
|
+
if (lines.scrollTop + lines.clientHeight >= lines.scrollHeight - 2) return this.#rowsAmount - 1
|
|
233
|
+
|
|
234
|
+
const top = lines.getBoundingClientRect().top
|
|
235
|
+
const firstVisible = this.#renderedRows().find(row => row.getBoundingClientRect().top >= top)
|
|
236
|
+
return firstVisible ? this.#indexOf(firstVisible) : 0
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#pageSize() {
|
|
240
|
+
return Math.max(1, Math.floor(this.linesTarget.clientHeight / this.#rowHeight()) - 2)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#rowHeight() {
|
|
244
|
+
return this.#clusterize?.options.item_height || 16
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#currentRow() {
|
|
248
|
+
if (this.index === null) return null
|
|
249
|
+
return this.#renderedRows()[this.index - this.#renderedStart] ?? null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#indexOf(row) {
|
|
253
|
+
const position = this.#renderedRows().indexOf(row)
|
|
254
|
+
return position === -1 ? null : this.#renderedStart + position
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
#rowFor(element) {
|
|
258
|
+
let node = element
|
|
259
|
+
while (node && node.parentElement !== this.#content) node = node.parentElement
|
|
260
|
+
return node && !node.classList.contains("clusterize-extra-row") ? node : null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#renderedRows() {
|
|
264
|
+
return [...this.#content.children].filter(row => !row.classList.contains("clusterize-extra-row"))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#textElement(row) {
|
|
268
|
+
return row.matches("pre") ? row : row.querySelector("pre")
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
get #renderedStart() {
|
|
272
|
+
return this.#clusterize?.getRenderedStart() ?? 0
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
get #rowsAmount() {
|
|
276
|
+
return this.#clusterize?.getRowsAmount() ?? 0
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
get #clusterize() {
|
|
280
|
+
return this.application.getControllerForElementAndIdentifier(this.element, "log-streamer")?.clusterize
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
get #content() {
|
|
284
|
+
return this.linesTarget.querySelector(".clusterize-content")
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -7,13 +7,16 @@ export default class LogStreamerController extends Controller {
|
|
|
7
7
|
autoScroll: { type: Boolean, default: true },
|
|
8
8
|
filter: { type: String, default: '' },
|
|
9
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 },
|
|
10
12
|
regexpMode: { type: Boolean, default: false },
|
|
13
|
+
caseInsensitive: { type: Boolean, default: false },
|
|
11
14
|
fileSize: { type: Number, default: 0 },
|
|
12
15
|
startPosition: { type: Number, default: 0 },
|
|
13
16
|
endPosition: { type: Number, default: 0 }
|
|
14
17
|
};
|
|
15
18
|
|
|
16
|
-
static targets = ["logLines", "filterInput", "results", "liveButton", "searchButton", "searchWholeFileButton", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll", "rangeSliderContainer", "startSlider", "endSlider", "searchPlaceholder"];
|
|
19
|
+
static targets = ["logLines", "filterInput", "results", "liveButton", "searchButton", "searchWholeFileButton", "message", "regexpMode", "caseInsensitive", "websocketStatus", "stopButton", "clearButton", "autoscroll", "rangeSliderContainer", "startSlider", "endSlider", "searchPlaceholder"];
|
|
17
20
|
|
|
18
21
|
// When the current live query started, so "0 matches" can say since when.
|
|
19
22
|
#liveFilterStartedAt = null;
|
|
@@ -96,6 +99,7 @@ export default class LogStreamerController extends Controller {
|
|
|
96
99
|
clear() {
|
|
97
100
|
this.clusterize.clear();
|
|
98
101
|
this.#updateResultsDisplay();
|
|
102
|
+
this.dispatch("cleared");
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
toggleAutoScroll() {
|
|
@@ -124,8 +128,16 @@ export default class LogStreamerController extends Controller {
|
|
|
124
128
|
}
|
|
125
129
|
}
|
|
126
130
|
|
|
131
|
+
toggleCaseInsensitive() {
|
|
132
|
+
this.caseInsensitiveValue = this.caseInsensitiveTarget.checked;
|
|
133
|
+
this.#updateUrlParam('case_insensitive', this.caseInsensitiveValue ? 'true' : null);
|
|
134
|
+
if (this.filterInputTarget.value && this.filterInputTarget.value.trim() !== '') {
|
|
135
|
+
this.reconnectWithNewMode();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
127
139
|
switchToLive() {
|
|
128
|
-
if (this.isLiveMode()) return;
|
|
140
|
+
if (this.isLiveMode() || !this.liveEnabledValue) return;
|
|
129
141
|
|
|
130
142
|
this.#clearHighlighting();
|
|
131
143
|
this.#setMode('live');
|
|
@@ -171,6 +183,25 @@ export default class LogStreamerController extends Controller {
|
|
|
171
183
|
this.reconnectWithNewMode();
|
|
172
184
|
}
|
|
173
185
|
|
|
186
|
+
currentSearch() {
|
|
187
|
+
return {
|
|
188
|
+
filter: this.filterInputTarget.value,
|
|
189
|
+
regexpMode: this.regexpModeTarget.checked,
|
|
190
|
+
caseInsensitive: this.caseInsensitiveTarget.checked
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
applySearch({ filter, regexpMode, caseInsensitive }) {
|
|
195
|
+
this.filterInputTarget.value = filter;
|
|
196
|
+
this.regexpModeTarget.checked = this.regexpModeValue = regexpMode;
|
|
197
|
+
this.caseInsensitiveTarget.checked = this.caseInsensitiveValue = caseInsensitive;
|
|
198
|
+
this.#updateUrlParams({
|
|
199
|
+
regexp_mode: regexpMode ? 'true' : null,
|
|
200
|
+
case_insensitive: caseInsensitive ? 'true' : null
|
|
201
|
+
});
|
|
202
|
+
this.applyFilter();
|
|
203
|
+
}
|
|
204
|
+
|
|
174
205
|
isLiveMode() {
|
|
175
206
|
return this.modeValue === 'live';
|
|
176
207
|
}
|
|
@@ -178,8 +209,11 @@ export default class LogStreamerController extends Controller {
|
|
|
178
209
|
// The only writer of modeValue. Everything that changes the mode goes through
|
|
179
210
|
// here so the switch, the URL and the toolbar layout can never disagree.
|
|
180
211
|
#setMode(mode) {
|
|
212
|
+
if (mode === 'live' && !this.liveEnabledValue) return;
|
|
213
|
+
|
|
181
214
|
this.modeValue = mode;
|
|
182
|
-
|
|
215
|
+
// Search is the only mode when live is disabled, so the URL need not say so.
|
|
216
|
+
this.#updateUrlParam('mode', mode === 'live' || !this.liveEnabledValue ? null : 'static');
|
|
183
217
|
this.#liveFilterStartedAt = mode === 'live' ? new Date() : null;
|
|
184
218
|
this.#syncModeControls();
|
|
185
219
|
}
|
|
@@ -287,6 +321,7 @@ export default class LogStreamerController extends Controller {
|
|
|
287
321
|
|
|
288
322
|
if (closestPre) {
|
|
289
323
|
this.#scrollVerticallyToCenter(closestPre);
|
|
324
|
+
this.dispatch("context-line", { detail: { row: this.#rowElement(closestPre) } });
|
|
290
325
|
}
|
|
291
326
|
}
|
|
292
327
|
|
|
@@ -427,6 +462,10 @@ export default class LogStreamerController extends Controller {
|
|
|
427
462
|
this.regexpModeTarget.checked = false;
|
|
428
463
|
}
|
|
429
464
|
|
|
465
|
+
const caseInsensitive = params.get('case_insensitive') === 'true';
|
|
466
|
+
this.caseInsensitiveValue = caseInsensitive;
|
|
467
|
+
this.caseInsensitiveTarget.checked = caseInsensitive;
|
|
468
|
+
|
|
430
469
|
// Restore range
|
|
431
470
|
const startParam = params.get('start_position');
|
|
432
471
|
const endParam = params.get('end_position');
|
|
@@ -435,8 +474,9 @@ export default class LogStreamerController extends Controller {
|
|
|
435
474
|
const end = endParam ? parseInt(endParam) : this.fileSizeValue;
|
|
436
475
|
this.#setRange(start, end);
|
|
437
476
|
|
|
438
|
-
// Calculate mode: check mode param, default to live
|
|
439
|
-
|
|
477
|
+
// Calculate mode: check mode param, default to live unless live is disabled
|
|
478
|
+
const wantsStatic = params.get('mode') === 'static' || !this.liveEnabledValue;
|
|
479
|
+
this.modeValue = wantsStatic ? 'static' : 'live';
|
|
440
480
|
this.#liveFilterStartedAt = this.isLiveMode() ? new Date() : null;
|
|
441
481
|
this.#syncModeControls();
|
|
442
482
|
}
|
|
@@ -514,7 +554,8 @@ export default class LogStreamerController extends Controller {
|
|
|
514
554
|
file_path: this.filePathValue,
|
|
515
555
|
filter: this.filterInputTarget.value,
|
|
516
556
|
mode: this.modeValue,
|
|
517
|
-
regexp_mode: this.regexpModeValue
|
|
557
|
+
regexp_mode: this.regexpModeValue,
|
|
558
|
+
case_insensitive: this.caseInsensitiveValue
|
|
518
559
|
};
|
|
519
560
|
|
|
520
561
|
// Use range slider values if available and not at defaults
|
|
@@ -706,7 +747,12 @@ export default class LogStreamerController extends Controller {
|
|
|
706
747
|
no_data_class: 'clusterize-no-data',
|
|
707
748
|
keep_parity: true,
|
|
708
749
|
callbacks: {
|
|
750
|
+
clusterWillChange: () => {
|
|
751
|
+
this.dispatch("rendering", { detail: { start: this.clusterize?.getRenderedStart() ?? 0 } });
|
|
752
|
+
},
|
|
709
753
|
clusterChanged: () => {
|
|
754
|
+
this.dispatch("rendered", { detail: { start: this.clusterize?.getRenderedStart() ?? 0 } });
|
|
755
|
+
|
|
710
756
|
// Re-apply highlighting when cluster changes (for virtual scrolling).
|
|
711
757
|
// The byte_offset URL param is the highlight anchor for an explore window.
|
|
712
758
|
// Only re-highlight if we've already done initial highlight.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Marks what the query matched on every rendered line. Clusterize only keeps a
|
|
4
|
+
// window of rows in the DOM and re-renders it from HTML strings, so the marks are
|
|
5
|
+
// re-applied whenever that window changes rather than baked into the rows.
|
|
6
|
+
// The query is re-compiled as a JavaScript RegExp; ripgrep syntax that JavaScript
|
|
7
|
+
// does not understand just leaves the lines unmarked.
|
|
8
|
+
export default class MatchHighlightController extends Controller {
|
|
9
|
+
static targets = ["lines", "filterInput", "regexpMode", "caseInsensitive"]
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this.observer = new MutationObserver(records => this.highlight(records))
|
|
13
|
+
this.observer.observe(this.#content, { childList: true })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnect() {
|
|
17
|
+
this.observer.disconnect()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
highlight(records) {
|
|
21
|
+
const pattern = this.#pattern()
|
|
22
|
+
if (!pattern) return
|
|
23
|
+
|
|
24
|
+
records.flatMap(record => [...record.addedNodes])
|
|
25
|
+
.filter(node => node.nodeType === Node.ELEMENT_NODE)
|
|
26
|
+
.flatMap(row => row.matches("pre") ? [row] : [...row.querySelectorAll(":scope > pre")])
|
|
27
|
+
.forEach(line => this.#mark(line, pattern))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#pattern() {
|
|
31
|
+
const query = this.filterInputTarget.value.trim()
|
|
32
|
+
if (query === "") return null
|
|
33
|
+
|
|
34
|
+
const source = this.regexpModeTarget.checked ? query : query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
35
|
+
try {
|
|
36
|
+
return new RegExp(source, this.caseInsensitiveTarget.checked ? "gi" : "g")
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A match can span several text nodes (ANSI colour spans, file links), so each
|
|
43
|
+
// one is wrapped piece by piece. Working backwards keeps the earlier offsets valid
|
|
44
|
+
// while the text nodes are being split.
|
|
45
|
+
#mark(line, pattern) {
|
|
46
|
+
const matches = [...line.textContent.matchAll(pattern)].filter(match => match[0].length > 0)
|
|
47
|
+
if (matches.length === 0) return
|
|
48
|
+
|
|
49
|
+
const nodes = []
|
|
50
|
+
const walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT)
|
|
51
|
+
let offset = 0
|
|
52
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
53
|
+
nodes.push({ node, start: offset, end: offset + node.length })
|
|
54
|
+
offset += node.length
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const match of matches.reverse()) {
|
|
58
|
+
const start = match.index
|
|
59
|
+
const end = start + match[0].length
|
|
60
|
+
|
|
61
|
+
for (const entry of [...nodes].reverse()) {
|
|
62
|
+
if (entry.start >= end || entry.end <= start) continue
|
|
63
|
+
|
|
64
|
+
const from = Math.max(start, entry.start) - entry.start
|
|
65
|
+
const to = Math.min(end, entry.end) - entry.start
|
|
66
|
+
|
|
67
|
+
if (to < entry.node.length) entry.node.splitText(to)
|
|
68
|
+
const piece = from > 0 ? entry.node.splitText(from) : entry.node
|
|
69
|
+
const mark = document.createElement("mark")
|
|
70
|
+
mark.className = "onlylogs-match"
|
|
71
|
+
piece.replaceWith(mark)
|
|
72
|
+
mark.append(piece)
|
|
73
|
+
|
|
74
|
+
entry.end = entry.start + from
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get #content() {
|
|
80
|
+
return this.linesTarget.querySelector(".clusterize-content")
|
|
81
|
+
}
|
|
82
|
+
}
|