onlylogs 0.11.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +251 -77
  3. data/app/assets/images/onlylogs/favicon/apple-touch-icon.png +0 -0
  4. data/app/assets/images/onlylogs/favicon/favicon-96x96.png +0 -0
  5. data/app/assets/images/onlylogs/favicon/favicon.ico +0 -0
  6. data/app/assets/images/onlylogs/favicon/favicon.svg +1 -3
  7. data/app/assets/images/onlylogs/favicon/web-app-manifest-192x192.png +0 -0
  8. data/app/assets/images/onlylogs/favicon/web-app-manifest-512x512.png +0 -0
  9. data/app/assets/images/onlylogs/logo.png +0 -0
  10. data/app/assets/javascripts/onlylogs/clusterize.js +26 -1
  11. data/app/channels/onlylogs/logs_channel.rb +14 -9
  12. data/app/controllers/concerns/onlylogs/authentication.rb +26 -0
  13. data/app/controllers/onlylogs/application_controller.rb +1 -18
  14. data/app/controllers/onlylogs/queries_controller.rb +70 -0
  15. data/app/controllers/onlylogs/viewer_controller.rb +7 -0
  16. data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +131 -21
  17. data/app/javascript/onlylogs/controllers/log_cursor_controller.js +286 -0
  18. data/app/javascript/onlylogs/controllers/log_streamer_controller.js +42 -2
  19. data/app/javascript/onlylogs/controllers/match_highlight_controller.js +82 -0
  20. data/app/javascript/onlylogs/controllers/queries_controller.js +470 -0
  21. data/app/javascript/onlylogs/controllers/range_slider_controller.js +5 -4
  22. data/app/javascript/onlylogs/controllers/text_selection_controller.js +136 -15
  23. data/app/models/onlylogs/file.rb +14 -5
  24. data/app/models/onlylogs/grep.rb +13 -6
  25. data/app/models/onlylogs/queries_record.rb +28 -0
  26. data/app/models/onlylogs/query.rb +29 -0
  27. data/app/views/onlylogs/logs/index.html.erb +1 -1
  28. data/app/views/onlylogs/shared/_log_container.html.erb +165 -27
  29. data/app/views/onlylogs/shared/_log_container_styles.html.erb +729 -44
  30. data/app/views/onlylogs/shared/_shortcuts_dialog.html.erb +62 -0
  31. data/bin/super_grep +7 -0
  32. data/bin/super_ripgrep +5 -0
  33. data/config/routes.rb +1 -0
  34. data/lib/onlylogs/configuration.rb +14 -1
  35. data/lib/onlylogs/http_device.rb +28 -13
  36. data/lib/onlylogs/version.rb +1 -1
  37. data/lib/onlylogs.rb +4 -0
  38. 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
+ }
@@ -10,12 +10,13 @@ export default class LogStreamerController extends Controller {
10
10
  // false for a file nothing writes to any more: the viewer only searches, never tails.
11
11
  liveEnabled: { type: Boolean, default: true },
12
12
  regexpMode: { type: Boolean, default: false },
13
+ caseInsensitive: { type: Boolean, default: false },
13
14
  fileSize: { type: Number, default: 0 },
14
15
  startPosition: { type: Number, default: 0 },
15
16
  endPosition: { type: Number, default: 0 }
16
17
  };
17
18
 
18
- 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"];
19
20
 
20
21
  // When the current live query started, so "0 matches" can say since when.
21
22
  #liveFilterStartedAt = null;
@@ -98,6 +99,7 @@ export default class LogStreamerController extends Controller {
98
99
  clear() {
99
100
  this.clusterize.clear();
100
101
  this.#updateResultsDisplay();
102
+ this.dispatch("cleared");
101
103
  }
102
104
 
103
105
  toggleAutoScroll() {
@@ -126,6 +128,14 @@ export default class LogStreamerController extends Controller {
126
128
  }
127
129
  }
128
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
+
129
139
  switchToLive() {
130
140
  if (this.isLiveMode() || !this.liveEnabledValue) return;
131
141
 
@@ -173,6 +183,25 @@ export default class LogStreamerController extends Controller {
173
183
  this.reconnectWithNewMode();
174
184
  }
175
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
+
176
205
  isLiveMode() {
177
206
  return this.modeValue === 'live';
178
207
  }
@@ -292,6 +321,7 @@ export default class LogStreamerController extends Controller {
292
321
 
293
322
  if (closestPre) {
294
323
  this.#scrollVerticallyToCenter(closestPre);
324
+ this.dispatch("context-line", { detail: { row: this.#rowElement(closestPre) } });
295
325
  }
296
326
  }
297
327
 
@@ -432,6 +462,10 @@ export default class LogStreamerController extends Controller {
432
462
  this.regexpModeTarget.checked = false;
433
463
  }
434
464
 
465
+ const caseInsensitive = params.get('case_insensitive') === 'true';
466
+ this.caseInsensitiveValue = caseInsensitive;
467
+ this.caseInsensitiveTarget.checked = caseInsensitive;
468
+
435
469
  // Restore range
436
470
  const startParam = params.get('start_position');
437
471
  const endParam = params.get('end_position');
@@ -520,7 +554,8 @@ export default class LogStreamerController extends Controller {
520
554
  file_path: this.filePathValue,
521
555
  filter: this.filterInputTarget.value,
522
556
  mode: this.modeValue,
523
- regexp_mode: this.regexpModeValue
557
+ regexp_mode: this.regexpModeValue,
558
+ case_insensitive: this.caseInsensitiveValue
524
559
  };
525
560
 
526
561
  // Use range slider values if available and not at defaults
@@ -712,7 +747,12 @@ export default class LogStreamerController extends Controller {
712
747
  no_data_class: 'clusterize-no-data',
713
748
  keep_parity: true,
714
749
  callbacks: {
750
+ clusterWillChange: () => {
751
+ this.dispatch("rendering", { detail: { start: this.clusterize?.getRenderedStart() ?? 0 } });
752
+ },
715
753
  clusterChanged: () => {
754
+ this.dispatch("rendered", { detail: { start: this.clusterize?.getRenderedStart() ?? 0 } });
755
+
716
756
  // Re-apply highlighting when cluster changes (for virtual scrolling).
717
757
  // The byte_offset URL param is the highlight anchor for an explore window.
718
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
+ }