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,470 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class QueriesController extends Controller {
4
+ static targets = [
5
+ "box", "toggle", "count", "star", "panel", "list", "empty", "footer", "footerLabel", "rowTemplate",
6
+ "savePanel", "saveTitle", "saveFilter", "saveFlags", "name", "saveButton", "hint", "savedRow",
7
+ ];
8
+ static values = {
9
+ logFilePath: String,
10
+ endpoint: String,
11
+ };
12
+
13
+ connect() {
14
+ this.queries = [];
15
+ this.activeQueryId = null;
16
+ this.pendingDeleteId = null;
17
+ this.renamingId = null;
18
+
19
+ this.boundCloseOnOutsideClick = (event) => {
20
+ if (!this.boxTarget.contains(event.target)) this.close();
21
+ };
22
+ document.addEventListener("click", this.boundCloseOnOutsideClick);
23
+
24
+ this.load();
25
+ }
26
+
27
+ disconnect() {
28
+ document.removeEventListener("click", this.boundCloseOnOutsideClick);
29
+ }
30
+
31
+ get #menuOpen() {
32
+ return !this.panelTarget.hidden;
33
+ }
34
+
35
+ get #saveOpen() {
36
+ return !this.savePanelTarget.hidden;
37
+ }
38
+
39
+ toggle() {
40
+ this.#menuOpen ? this.close() : this.#openMenu();
41
+ }
42
+
43
+ toggleSave() {
44
+ this.#saveOpen ? this.close() : this.openSave();
45
+ }
46
+
47
+ openSave() {
48
+ if (!this.#hasFilter) return;
49
+
50
+ this.close();
51
+ const current = this.#current;
52
+ const name = this.#matching?.name ?? this.#active?.name ?? current.filter;
53
+
54
+ this.saveFilterTarget.textContent = current.filter;
55
+ this.saveFlagsTarget.replaceChildren(...this.#flags(current.regexpMode, current.caseInsensitive));
56
+ this.nameTarget.value = name;
57
+ this.savePanelTarget.hidden = false;
58
+ this.starTarget.setAttribute("aria-expanded", "true");
59
+ this.#renderSavePanel();
60
+
61
+ this.nameTarget.focus();
62
+ this.nameTarget.select();
63
+ }
64
+
65
+ close({ restoreFocus = false } = {}) {
66
+ if (!this.#menuOpen && !this.#saveOpen) return;
67
+
68
+ const trigger = this.#saveOpen ? this.starTarget : this.toggleTarget;
69
+ this.panelTarget.hidden = true;
70
+ this.savePanelTarget.hidden = true;
71
+ this.toggleTarget.setAttribute("aria-expanded", "false");
72
+ this.starTarget.setAttribute("aria-expanded", "false");
73
+ this.pendingDeleteId = null;
74
+ this.renamingId = null;
75
+ if (restoreFocus) trigger.focus();
76
+ }
77
+
78
+ navigate(event) {
79
+ if (!this.#menuOpen && !this.#saveOpen) return;
80
+
81
+ if (event.key === "Escape") {
82
+ event.preventDefault();
83
+ event.stopPropagation();
84
+ this.close({ restoreFocus: true });
85
+ return;
86
+ }
87
+
88
+ if (!this.#menuOpen || !["ArrowDown", "ArrowUp"].includes(event.key)) return;
89
+
90
+ const items = [...this.panelTarget.querySelectorAll(".saved-search__apply, .saved-searches__footer-button")]
91
+ .filter((item) => item.checkVisibility());
92
+ const index = items.indexOf(document.activeElement);
93
+ if (items.length === 0) return;
94
+
95
+ event.preventDefault();
96
+ const step = event.key === "ArrowDown" ? 1 : -1;
97
+ items[index === -1 ? 0 : (index + step + items.length) % items.length].focus();
98
+ }
99
+
100
+ refresh() {
101
+ this.#render();
102
+ }
103
+
104
+ cleared() {
105
+ this.activeQueryId = null;
106
+ this.close();
107
+ this.#render();
108
+ }
109
+
110
+ nameChanged() {
111
+ this.#renderSavePanel();
112
+ }
113
+
114
+ async save(event) {
115
+ event.preventDefault();
116
+
117
+ const name = this.nameTarget.value.trim();
118
+ const current = this.#current;
119
+ if (!name || !this.#hasFilter) return;
120
+
121
+ const matching = this.#matching;
122
+ if (matching && matching.name === name) {
123
+ this.activeQueryId = matching.id;
124
+ this.close({ restoreFocus: true });
125
+ this.#render();
126
+ return;
127
+ }
128
+
129
+ const existing = matching ?? this.#findByName(name);
130
+ const attributes = matching
131
+ ? { name }
132
+ : { name, filter: current.filter, regexp_mode: current.regexpMode, case_insensitive: current.caseInsensitive };
133
+ const query = await this.#request(existing ? this.#url(`/${existing.id}`) : this.#url(), {
134
+ method: existing ? "PATCH" : "POST",
135
+ body: JSON.stringify({ query: attributes }),
136
+ });
137
+ if (!query) return;
138
+
139
+ this.#store(query);
140
+ this.activeQueryId = query.id;
141
+ this.close({ restoreFocus: true });
142
+ this.#render();
143
+ this.#showMessage(matching ? `Renamed to "${query.name}"` : `${existing ? "Updated" : "Saved as"} "${query.name}"`, "success");
144
+ }
145
+
146
+ apply(event) {
147
+ const query = this.#find(this.#queryId(event));
148
+ if (!query || !this.#streamer) return;
149
+
150
+ this.activeQueryId = query.id;
151
+ this.#streamer.applySearch({
152
+ filter: query.filter,
153
+ regexpMode: query.regexp_mode,
154
+ caseInsensitive: query.case_insensitive,
155
+ });
156
+ this.close();
157
+ this.#render();
158
+ this.#streamer.filterInputTarget.focus();
159
+ }
160
+
161
+ rename(event) {
162
+ event.stopPropagation();
163
+ this.renamingId = this.#queryId(event);
164
+ this.pendingDeleteId = null;
165
+ this.#renderList();
166
+
167
+ const input = this.#row(this.renamingId)?.querySelector(".saved-search__rename-input");
168
+ input?.focus();
169
+ input?.select();
170
+ }
171
+
172
+ renameKey(event) {
173
+ if (event.key === "Enter") {
174
+ event.preventDefault();
175
+ event.currentTarget.blur();
176
+ } else if (event.key === "Escape") {
177
+ event.preventDefault();
178
+ event.stopPropagation();
179
+ this.renamingId = null;
180
+ this.#renderList();
181
+ this.#row(this.#queryId(event))?.querySelector(".saved-search__apply")?.focus();
182
+ }
183
+ }
184
+
185
+ async commitRename(event) {
186
+ const id = this.#queryId(event);
187
+ if (this.renamingId !== id) return;
188
+
189
+ this.renamingId = null;
190
+ const query = this.#find(id);
191
+ const name = event.currentTarget.value.trim();
192
+ if (!query || !name || name === query.name) {
193
+ this.#renderList();
194
+ return;
195
+ }
196
+
197
+ const renamed = await this.#request(this.#url(`/${id}`), {
198
+ method: "PATCH",
199
+ body: JSON.stringify({ query: { name } }),
200
+ });
201
+ if (renamed) this.#store(renamed, { keepPosition: true });
202
+ this.#render();
203
+ }
204
+
205
+ async destroy(event) {
206
+ event.stopPropagation();
207
+ const id = this.#queryId(event);
208
+
209
+ if (this.pendingDeleteId !== id) {
210
+ this.pendingDeleteId = id;
211
+ this.#renderList();
212
+ this.#row(id)?.querySelector(".saved-search__delete")?.focus();
213
+ return;
214
+ }
215
+
216
+ this.pendingDeleteId = null;
217
+ if (await this.#delete(id)) this.toggleTarget.focus();
218
+ }
219
+
220
+ async removeCurrent() {
221
+ const matching = this.#matching;
222
+ if (!matching) return;
223
+
224
+ this.close();
225
+ if (await this.#delete(matching.id)) this.starTarget.focus();
226
+ }
227
+
228
+ async load() {
229
+ const data = await this.#request(this.#url());
230
+ if (!data) return;
231
+
232
+ this.queries = data.queries;
233
+ this.#render();
234
+ }
235
+
236
+ #openMenu() {
237
+ this.close();
238
+ this.panelTarget.hidden = false;
239
+ this.toggleTarget.setAttribute("aria-expanded", "true");
240
+ this.#render();
241
+
242
+ const matching = this.#matching;
243
+ const first = (matching && this.#row(matching.id)) || this.listTarget.querySelector(".saved-search");
244
+ (first?.querySelector(".saved-search__apply") ?? this.footerTarget.querySelector("button")).focus();
245
+ }
246
+
247
+ async #delete(id) {
248
+ const query = this.#find(id);
249
+ const deleted = await this.#request(this.#url(`/${id}`), { method: "DELETE" });
250
+ if (deleted === null) return false;
251
+
252
+ this.queries = this.queries.filter((q) => q.id !== id);
253
+ if (this.activeQueryId === id) this.activeQueryId = null;
254
+ this.#render();
255
+ this.#showMessage(`Deleted "${query?.name}"`, "success");
256
+ return true;
257
+ }
258
+
259
+ #store(query, { keepPosition = false } = {}) {
260
+ const index = this.queries.findIndex((q) => q.id === query.id);
261
+ if (keepPosition && index !== -1) {
262
+ this.queries = this.queries.with(index, query);
263
+ } else {
264
+ this.queries = [query, ...this.queries.filter((q) => q.id !== query.id)];
265
+ }
266
+ }
267
+
268
+ #render() {
269
+ this.#renderField();
270
+ if (this.#menuOpen) this.#renderMenu();
271
+ if (this.#saveOpen) this.#renderSavePanel();
272
+ }
273
+
274
+ #renderField() {
275
+ const matching = this.#matching;
276
+ const count = this.queries.length;
277
+
278
+ this.countTarget.textContent = count > 0 ? count : "";
279
+ this.toggleTarget.title = count > 0 ? `Saved searches (${count})` : "Saved searches";
280
+
281
+ this.starTarget.disabled = !this.#hasFilter;
282
+ this.starTarget.classList.toggle("saved-searches__star--saved", Boolean(matching));
283
+ this.starTarget.title = !this.#hasFilter
284
+ ? "Type a pattern to save it"
285
+ : matching ? `Saved as "${matching.name}"` : "Save this search";
286
+ this.starTarget.setAttribute("aria-label", this.starTarget.title);
287
+ }
288
+
289
+ #renderMenu() {
290
+ this.emptyTarget.hidden = this.queries.length > 0;
291
+ this.#renderList();
292
+
293
+ const showFooter = this.#hasFilter && !this.#matching;
294
+ this.footerTarget.hidden = !showFooter;
295
+ if (showFooter) {
296
+ this.footerLabelTarget.textContent = this.#edited
297
+ ? `Save changes to "${this.#active.name}"…`
298
+ : `Save current search "${this.#current.filter}"`;
299
+ }
300
+ }
301
+
302
+ #renderList() {
303
+ const matching = this.#matching;
304
+ const edited = this.#edited;
305
+
306
+ this.listTarget.replaceChildren(...this.queries.map((query) => {
307
+ const item = this.rowTemplateTarget.content.firstElementChild.cloneNode(true);
308
+ item.dataset.queryId = query.id;
309
+ item.toggleAttribute("aria-current", matching?.id === query.id);
310
+
311
+ const apply = item.querySelector(".saved-search__apply");
312
+ apply.title = `Apply "${query.name}"`;
313
+ item.querySelector(".saved-search__name").textContent = query.name;
314
+ item.querySelector(".saved-search__check").toggleAttribute("hidden", matching?.id !== query.id);
315
+ item.querySelector(".saved-search__edited").hidden = !(edited && this.activeQueryId === query.id);
316
+ item.querySelector(".saved-search__filter").textContent = query.filter;
317
+ item.querySelector(".saved-search__flags").replaceChildren(...this.#flags(query.regexp_mode, query.case_insensitive));
318
+
319
+ const renaming = this.renamingId === query.id;
320
+ const input = item.querySelector(".saved-search__rename-input");
321
+ input.hidden = !renaming;
322
+ input.value = query.name;
323
+ input.setAttribute("aria-label", `New name for "${query.name}"`);
324
+ apply.hidden = renaming;
325
+
326
+ const rename = item.querySelector(".saved-search__rename");
327
+ rename.hidden = renaming;
328
+ rename.title = `Rename "${query.name}"`;
329
+ rename.setAttribute("aria-label", rename.title);
330
+
331
+ const remove = item.querySelector(".saved-search__delete");
332
+ const confirming = this.pendingDeleteId === query.id;
333
+ remove.hidden = renaming;
334
+ remove.classList.toggle("saved-search__delete--confirm", confirming);
335
+ remove.title = confirming ? "Click again to delete" : `Delete "${query.name}"`;
336
+ remove.setAttribute("aria-label", remove.title);
337
+
338
+ return item;
339
+ }));
340
+ }
341
+
342
+ #renderSavePanel() {
343
+ const matching = this.#matching;
344
+ const name = this.nameTarget.value.trim();
345
+ const byName = name === "" ? undefined : this.#findByName(name);
346
+ const replacing = byName && byName.id !== matching?.id ? byName : undefined;
347
+ const active = this.#active;
348
+
349
+ let title = "Save this search";
350
+ let label = "Save";
351
+ let hint = "Saved searches show up in the list next to the star.";
352
+ if (matching) {
353
+ title = "Saved search";
354
+ label = name === matching.name ? "Done" : "Rename";
355
+ hint = "Change the name, or press Done.";
356
+ } else if (replacing) {
357
+ label = "Update";
358
+ hint = `Replaces the pattern saved as "${replacing.name}".`;
359
+ } else if (this.#edited) {
360
+ title = "Save changes";
361
+ hint = `Keep the name "${active.name}" to update it, or type a new name to save a copy.`;
362
+ }
363
+
364
+ this.saveTitleTarget.textContent = title;
365
+ this.saveButtonTarget.textContent = label;
366
+ this.saveButtonTarget.disabled = name === "";
367
+ this.hintTarget.textContent = hint;
368
+ this.hintTarget.classList.toggle("save-search__hint--warning", Boolean(replacing));
369
+ this.savedRowTarget.hidden = !matching;
370
+ }
371
+
372
+ #flags(regexpMode, caseInsensitive) {
373
+ return [[regexpMode, "regexp"], [caseInsensitive, "ignore case"]]
374
+ .filter(([on]) => on)
375
+ .map(([, label]) => {
376
+ const flag = document.createElement("span");
377
+ flag.className = "saved-search__flag";
378
+ flag.textContent = label;
379
+ return flag;
380
+ });
381
+ }
382
+
383
+ get #current() {
384
+ return this.#streamer?.currentSearch() ?? { filter: "", regexpMode: false, caseInsensitive: false };
385
+ }
386
+
387
+ get #hasFilter() {
388
+ return this.#current.filter.trim() !== "";
389
+ }
390
+
391
+ get #matching() {
392
+ if (!this.#hasFilter) return undefined;
393
+
394
+ const current = this.#current;
395
+ return this.queries.find((query) =>
396
+ query.filter === current.filter &&
397
+ query.regexp_mode === current.regexpMode &&
398
+ query.case_insensitive === current.caseInsensitive);
399
+ }
400
+
401
+ get #active() {
402
+ return this.#find(this.activeQueryId);
403
+ }
404
+
405
+ get #edited() {
406
+ return Boolean(this.#active && this.#hasFilter && !this.#matching);
407
+ }
408
+
409
+ #queryId(event) {
410
+ return Number(event.currentTarget.closest(".saved-search").dataset.queryId);
411
+ }
412
+
413
+ #row(id) {
414
+ return this.listTarget.querySelector(`.saved-search[data-query-id="${id}"]`);
415
+ }
416
+
417
+ #find(id) {
418
+ return this.queries.find((q) => q.id === id);
419
+ }
420
+
421
+ #findByName(name) {
422
+ const key = name.toLowerCase();
423
+ return this.queries.find((q) => q.name.toLowerCase() === key);
424
+ }
425
+
426
+ #url(suffix = "") {
427
+ return `${this.endpointValue}${suffix}?log_file_path=${encodeURIComponent(this.logFilePathValue)}`;
428
+ }
429
+
430
+ async #request(url, options = {}) {
431
+ try {
432
+ const response = await fetch(url, {
433
+ ...options,
434
+ headers: {
435
+ Accept: "application/json",
436
+ "Content-Type": "application/json",
437
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content || "",
438
+ },
439
+ });
440
+ const body = response.status === 204 ? {} : await response.json().catch(() => ({}));
441
+
442
+ if (!response.ok) {
443
+ this.#showMessage(body.error || `Saved searches: request failed (${response.status})`, "error");
444
+ return null;
445
+ }
446
+ return body;
447
+ } catch {
448
+ this.#showMessage("Saved searches are unreachable", "error");
449
+ return null;
450
+ }
451
+ }
452
+
453
+ get #streamer() {
454
+ return this.application.getControllerForElementAndIdentifier(this.element, "log-streamer");
455
+ }
456
+
457
+ #showMessage(message, type) {
458
+ const messageElement = this.#streamer?.messageTarget;
459
+ if (!messageElement) return;
460
+
461
+ const span = document.createElement("span");
462
+ span.className = type === "error" ? "error-message" : "success-message";
463
+ span.textContent = message;
464
+ messageElement.replaceChildren(span);
465
+
466
+ setTimeout(() => {
467
+ if (span.isConnected) span.remove();
468
+ }, 4000);
469
+ }
470
+ }
@@ -34,8 +34,8 @@ export default class RangeSliderController extends Controller {
34
34
  this.#paint(start, end);
35
35
  }
36
36
 
37
- // Reads the handles, snapping to the ends and keeping start <= end, and
38
- // writes back whatever it had to correct.
37
+ // Reads the handles, snapping the end to the file end and keeping
38
+ // start <= end, and writes back whatever it had to correct.
39
39
  #clampedValues() {
40
40
  let start = Number(this.startInputTarget.value);
41
41
  let end = Number(this.endInputTarget.value);
@@ -43,10 +43,11 @@ export default class RangeSliderController extends Controller {
43
43
  const sliderMax = Number(this.startInputTarget.max);
44
44
  const step = Number(this.startInputTarget.step);
45
45
 
46
- // Snap to 100% if close to max (within 2% or one step)
46
+ // Snap the end to 100% if close to max (within 2% or one step). Only the end:
47
+ // a start that close to the end is a narrow window on the file's tail, and
48
+ // snapping it too would collapse the range to nothing.
47
49
  const threshold = Math.max(sliderMax * 0.02, step);
48
50
  if (end > sliderMax - threshold) end = sliderMax;
49
- if (start > sliderMax - threshold) start = sliderMax;
50
51
 
51
52
  if (start > end) [start, end] = [end, start];
52
53