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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +270 -69
  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 +22 -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 +52 -6
  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 +27 -7
  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 +179 -30
  29. data/app/views/onlylogs/shared/_log_container_styles.html.erb +731 -45
  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
+ }
@@ -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
- this.#updateUrlParam('mode', mode === 'live' ? null : 'static');
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
- this.modeValue = params.get('mode') === 'static' ? 'static' : 'live';
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
+ }