onlylogs 0.1.2
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 +7 -0
- data/README.md +311 -0
- data/Rakefile +8 -0
- data/app/assets/config/onlylogs_manifest.js +2 -0
- 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 +3 -0
- data/app/assets/images/onlylogs/favicon/site.webmanifest.erb +21 -0
- 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/channels/onlylogs/application_cable/channel.rb +11 -0
- data/app/channels/onlylogs/logs_channel.rb +181 -0
- data/app/controllers/onlylogs/application_controller.rb +22 -0
- data/app/controllers/onlylogs/logs_controller.rb +23 -0
- data/app/helpers/onlylogs/application_helper.rb +4 -0
- data/app/javascript/onlylogs/application.js +1 -0
- data/app/javascript/onlylogs/controllers/application.js +9 -0
- data/app/javascript/onlylogs/controllers/index.js +11 -0
- data/app/javascript/onlylogs/controllers/keyboard_shortcuts_controller.js +46 -0
- data/app/javascript/onlylogs/controllers/log_streamer_controller.js +432 -0
- data/app/javascript/onlylogs/controllers/text_selection_controller.js +90 -0
- data/app/jobs/onlylogs/application_job.rb +4 -0
- data/app/models/onlylogs/ansi_color_parser.rb +78 -0
- data/app/models/onlylogs/application_record.rb +5 -0
- data/app/models/onlylogs/batch_sender.rb +61 -0
- data/app/models/onlylogs/file.rb +151 -0
- data/app/models/onlylogs/file_path_parser.rb +118 -0
- data/app/models/onlylogs/grep.rb +54 -0
- data/app/models/onlylogs/log_line.rb +24 -0
- data/app/models/onlylogs/secure_file_path.rb +31 -0
- data/app/views/home/show.html.erb +10 -0
- data/app/views/layouts/onlylogs/application.html.erb +27 -0
- data/app/views/onlylogs/logs/index.html.erb +49 -0
- data/app/views/onlylogs/shared/_log_container.html.erb +106 -0
- data/app/views/onlylogs/shared/_log_container_styles.html.erb +228 -0
- data/config/importmap.rb +6 -0
- data/config/puma_plugins/vector.rb +94 -0
- data/config/routes.rb +4 -0
- data/config/udp_logger.rb +40 -0
- data/config/vector.toml +32 -0
- data/db/migrate/20250902112548_create_books.rb +9 -0
- data/lib/onlylogs/configuration.rb +133 -0
- data/lib/onlylogs/engine.rb +39 -0
- data/lib/onlylogs/formatter.rb +14 -0
- data/lib/onlylogs/log_silencer_middleware.rb +26 -0
- data/lib/onlylogs/logger.rb +10 -0
- data/lib/onlylogs/socket_logger.rb +71 -0
- data/lib/onlylogs/version.rb +3 -0
- data/lib/onlylogs.rb +17 -0
- data/lib/puma/plugin/onlylogs_sidecar.rb +113 -0
- data/lib/tasks/onlylogs_tasks.rake +4 -0
- metadata +110 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Onlylogs
|
|
2
|
+
class ApplicationController < (Onlylogs.parent_controller&.constantize || ActionController::Base)
|
|
3
|
+
before_action :authenticate_onlylogs_user!
|
|
4
|
+
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def authenticate_onlylogs_user!
|
|
8
|
+
return super if defined?(super)
|
|
9
|
+
|
|
10
|
+
return if Onlylogs.disable_basic_authentication?
|
|
11
|
+
|
|
12
|
+
unless Onlylogs.basic_auth_configured?
|
|
13
|
+
render plain: "Onlylogs authentication not configured. Please configure basic auth credentials.", status: :forbidden
|
|
14
|
+
return
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
authenticate_or_request_with_http_basic("onlylogs") do |username, password|
|
|
18
|
+
username == Onlylogs.basic_auth_user && password == Onlylogs.basic_auth_password
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Onlylogs
|
|
4
|
+
class LogsController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@max_lines = (params[:max_lines] || 100).to_i
|
|
7
|
+
|
|
8
|
+
# Get the file path from params or use default
|
|
9
|
+
@log_file_path = params[:log_file_path] || default_log_file_path
|
|
10
|
+
|
|
11
|
+
@filter = params[:filter]
|
|
12
|
+
@autoscroll = params[:autoscroll] != "false"
|
|
13
|
+
@mode = @filter.blank? ? (params[:mode] || "live") : "search" # "live" or "search"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def default_log_file_path
|
|
19
|
+
# "/Users/alessandrorodi/RenuoWorkspace/onlylogs/test/fixtures/files/very_big.log"
|
|
20
|
+
Onlylogs.default_log_file_path
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "controllers"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Import and register all your controllers from the importmap under controllers/*
|
|
2
|
+
|
|
3
|
+
import { application } from "controllers/application"
|
|
4
|
+
|
|
5
|
+
// Eager load all controllers defined in the import map under controllers/**/*_controller
|
|
6
|
+
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
|
|
7
|
+
eagerLoadControllersFrom("controllers", application)
|
|
8
|
+
|
|
9
|
+
// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!)
|
|
10
|
+
// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
|
|
11
|
+
// lazyLoadControllersFrom("controllers", application)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class KeyboardShortcutsController extends Controller {
|
|
4
|
+
static targets = ["liveMode", "autoscroll"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this.boundHandleKeydown = this.handleKeydown.bind(this)
|
|
8
|
+
document.addEventListener('keydown', this.boundHandleKeydown)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
disconnect() {
|
|
12
|
+
document.removeEventListener('keydown', this.boundHandleKeydown)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
handleKeydown(event) {
|
|
16
|
+
// Only handle shortcuts when not typing in input fields
|
|
17
|
+
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
switch (event.key.toLowerCase()) {
|
|
22
|
+
case 'l':
|
|
23
|
+
event.preventDefault()
|
|
24
|
+
this.toggleLiveMode()
|
|
25
|
+
break
|
|
26
|
+
case 'a':
|
|
27
|
+
event.preventDefault()
|
|
28
|
+
this.toggleAutoscroll()
|
|
29
|
+
break
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
toggleLiveMode() {
|
|
34
|
+
if (this.hasLiveModeTarget) {
|
|
35
|
+
this.liveModeTarget.checked = !this.liveModeTarget.checked
|
|
36
|
+
this.liveModeTarget.dispatchEvent(new Event('change', { bubbles: true }))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
toggleAutoscroll() {
|
|
41
|
+
if (this.hasAutoscrollTarget) {
|
|
42
|
+
this.autoscrollTarget.checked = !this.autoscrollTarget.checked
|
|
43
|
+
this.autoscrollTarget.dispatchEvent(new Event('change', { bubbles: true }))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
import { createConsumer } from "@rails/actioncable";
|
|
3
|
+
|
|
4
|
+
export default class LogStreamerController extends Controller {
|
|
5
|
+
static values = {
|
|
6
|
+
filePath: { type: String },
|
|
7
|
+
cursorPosition: { type: Number, default: 0 },
|
|
8
|
+
lastLineNumber: { type: Number, default: 0 },
|
|
9
|
+
autoScroll: { type: Boolean, default: true },
|
|
10
|
+
autoStart: { type: Boolean, default: true },
|
|
11
|
+
filter: { type: String, default: '' },
|
|
12
|
+
mode: { type: String, default: 'live' },
|
|
13
|
+
regexpMode: { type: Boolean, default: false }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
static targets = ["logLines", "filterInput", "lineRange", "liveMode", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton"];
|
|
17
|
+
|
|
18
|
+
connect() {
|
|
19
|
+
this.consumer = createConsumer();
|
|
20
|
+
|
|
21
|
+
this.subscription = null;
|
|
22
|
+
this.isRunning = false;
|
|
23
|
+
this.reconnectTimeout = null;
|
|
24
|
+
this.minLineNumber = null;
|
|
25
|
+
this.maxLineNumber = 0;
|
|
26
|
+
this.isSearchFinished = true;
|
|
27
|
+
|
|
28
|
+
// Initialize clusterize
|
|
29
|
+
this.clusterize = null;
|
|
30
|
+
this.#initializeClusterize();
|
|
31
|
+
|
|
32
|
+
this.#updateWebsocketStatus('disconnected');
|
|
33
|
+
|
|
34
|
+
this.start();
|
|
35
|
+
this.updateLiveModeState();
|
|
36
|
+
this.scroll();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
disconnect() {
|
|
40
|
+
this.stop();
|
|
41
|
+
|
|
42
|
+
// Clear any pending reconnect timeout
|
|
43
|
+
if (this.reconnectTimeout) {
|
|
44
|
+
clearTimeout(this.reconnectTimeout);
|
|
45
|
+
this.reconnectTimeout = null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Destroy clusterize instance
|
|
49
|
+
if (this.clusterize) {
|
|
50
|
+
this.clusterize.destroy();
|
|
51
|
+
this.clusterize = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
start() {
|
|
56
|
+
if (this.isRunning) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.isRunning = true;
|
|
61
|
+
this.#createSubscription();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
stop() {
|
|
65
|
+
if (!this.isRunning) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.isRunning = false;
|
|
70
|
+
|
|
71
|
+
if (this.subscription) {
|
|
72
|
+
this.stopSearch();
|
|
73
|
+
this.subscription.unsubscribe();
|
|
74
|
+
this.subscription = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
reset() {
|
|
79
|
+
this.stop();
|
|
80
|
+
this.clear();
|
|
81
|
+
this.#reinitializeClusterize();
|
|
82
|
+
this.start();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
clear() {
|
|
86
|
+
this.minLineNumber = null;
|
|
87
|
+
this.maxLineNumber = 0;
|
|
88
|
+
|
|
89
|
+
this.clusterize.clear();
|
|
90
|
+
|
|
91
|
+
this.#updateLineRangeDisplay();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
toggleAutoScroll() {
|
|
95
|
+
this.autoScrollValue = !this.autoScrollValue;
|
|
96
|
+
this.scroll();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
toggleRegexpMode() {
|
|
100
|
+
this.regexpModeValue = this.regexpModeTarget.checked;
|
|
101
|
+
// If we have a filter applied, reconnect to apply the new regexp mode
|
|
102
|
+
if (this.filterInputTarget.value && this.filterInputTarget.value.trim() !== '') {
|
|
103
|
+
this.reconnectWithNewMode();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
toggleLiveMode() {
|
|
108
|
+
// this condition looks revered, but the value here has been changed already. so the live mode has been enabled.
|
|
109
|
+
if (this.isLiveMode()) {
|
|
110
|
+
this.clearFilter();
|
|
111
|
+
this.modeValue = 'live';
|
|
112
|
+
this.reconnectWithNewMode();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
this.liveModeTarget.checked = true;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
applyFilter() {
|
|
122
|
+
// If filter is applied, disable live mode
|
|
123
|
+
if (this.filterInputTarget.value && this.filterInputTarget.value.trim() !== '') {
|
|
124
|
+
this.liveModeTarget.checked = false;
|
|
125
|
+
this.modeValue = 'search';
|
|
126
|
+
} else {
|
|
127
|
+
// If no filter, enable live mode
|
|
128
|
+
this.liveModeTarget.checked = true;
|
|
129
|
+
this.modeValue = 'live';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Update visual state
|
|
133
|
+
this.updateLiveModeState();
|
|
134
|
+
this.updateStopButtonVisibility();
|
|
135
|
+
|
|
136
|
+
// Use the global debounced reconnection (300ms delay)
|
|
137
|
+
this.reconnectWithNewMode();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
isLiveMode() {
|
|
141
|
+
return this.liveModeTarget.checked;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
scroll() {
|
|
145
|
+
if (this.autoScrollValue) {
|
|
146
|
+
this.logLinesTarget.scrollTop = this.logLinesTarget.scrollHeight;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
reconnectWithNewMode() {
|
|
151
|
+
// Clear any existing reconnect timeout
|
|
152
|
+
if (this.reconnectTimeout) {
|
|
153
|
+
clearTimeout(this.reconnectTimeout);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Debounce reconnection to avoid multiple rapid reconnections
|
|
157
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
158
|
+
this.stop();
|
|
159
|
+
this.clear();
|
|
160
|
+
this.#reinitializeClusterize();
|
|
161
|
+
this.start();
|
|
162
|
+
this.reconnectTimeout = null;
|
|
163
|
+
}, 600);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
clearFilter() {
|
|
167
|
+
// Clear the filter input
|
|
168
|
+
this.filterInputTarget.value = '';
|
|
169
|
+
|
|
170
|
+
// Re-enable live mode
|
|
171
|
+
this.liveModeTarget.checked = true;
|
|
172
|
+
this.modeValue = 'live';
|
|
173
|
+
|
|
174
|
+
// Update visual state
|
|
175
|
+
this.updateLiveModeState();
|
|
176
|
+
this.updateStopButtonVisibility();
|
|
177
|
+
|
|
178
|
+
// Reconnect with cleared filter and live mode
|
|
179
|
+
this.reconnectWithNewMode();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
stopSearch() {
|
|
183
|
+
if (this.subscription && this.isRunning) {
|
|
184
|
+
this.subscription.perform('stop_watcher');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
clearLogs() {
|
|
189
|
+
this.clear();
|
|
190
|
+
this.#hideMessage();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
updateLiveModeState() {
|
|
194
|
+
const liveModeLabel = this.liveModeTarget.closest('label');
|
|
195
|
+
|
|
196
|
+
if (this.isLiveMode()) {
|
|
197
|
+
liveModeLabel.classList.remove('live-mode-sticky');
|
|
198
|
+
} else {
|
|
199
|
+
liveModeLabel.classList.add('live-mode-sticky');
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
updateStopButtonVisibility() {
|
|
204
|
+
const shouldShow = !this.isLiveMode() && this.subscription && this.isRunning && !this.isSearchFinished;
|
|
205
|
+
this.stopButtonTarget.style.display = shouldShow ? 'inline-block' : 'none';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Create ActionCable subscription
|
|
211
|
+
*/
|
|
212
|
+
#createSubscription() {
|
|
213
|
+
this.subscription = this.consumer.subscriptions.create("Onlylogs::LogsChannel", {
|
|
214
|
+
connected: () => {
|
|
215
|
+
this.#handleConnected();
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
disconnected: () => {
|
|
219
|
+
this.#handleDisconnected();
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
rejected: () => {
|
|
223
|
+
this.#handleRejected();
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
received: (data) => {
|
|
227
|
+
if (data.action === 'append_logs') {
|
|
228
|
+
this.isSearchFinished = this.isLiveMode();
|
|
229
|
+
this.#handleLogLines(data.lines);
|
|
230
|
+
} else if (data.action === 'message') {
|
|
231
|
+
this.#handleMessage(data.content);
|
|
232
|
+
} else if (data.action === 'finish') {
|
|
233
|
+
this.#handleFinish(data.content);
|
|
234
|
+
} else if (data.action === 'error') {
|
|
235
|
+
this.#handleError(data.content);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Handle successful connection
|
|
243
|
+
*/
|
|
244
|
+
#handleConnected() {
|
|
245
|
+
this.subscription.perform('initialize_watcher', {
|
|
246
|
+
cursor_position: this.cursorPositionValue,
|
|
247
|
+
last_line_number: this.lastLineNumberValue,
|
|
248
|
+
file_path: this.filePathValue,
|
|
249
|
+
filter: this.filterInputTarget.value,
|
|
250
|
+
mode: this.modeValue,
|
|
251
|
+
regexp_mode: this.regexpModeValue
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
this.element.classList.add("log-streamer--connected");
|
|
255
|
+
this.element.classList.remove("log-streamer--disconnected", "log-streamer--rejected");
|
|
256
|
+
this.#updateWebsocketStatus('connected');
|
|
257
|
+
this.updateStopButtonVisibility();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#handleDisconnected() {
|
|
261
|
+
this.element.classList.add("log-streamer--disconnected");
|
|
262
|
+
this.element.classList.remove("log-streamer--connected");
|
|
263
|
+
this.#updateWebsocketStatus('disconnected');
|
|
264
|
+
this.updateStopButtonVisibility();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#handleRejected() {
|
|
268
|
+
this.element.classList.add("log-streamer--rejected");
|
|
269
|
+
this.element.classList.remove("log-streamer--connected", "log-streamer--disconnected");
|
|
270
|
+
this.#updateWebsocketStatus('rejected');
|
|
271
|
+
this.updateStopButtonVisibility();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#handleLogLines(lines) {
|
|
275
|
+
try {
|
|
276
|
+
const newLines = [];
|
|
277
|
+
|
|
278
|
+
lines.forEach(line => {
|
|
279
|
+
const { line_number, html } = line;
|
|
280
|
+
|
|
281
|
+
if (this.minLineNumber === null || line_number < this.minLineNumber) {
|
|
282
|
+
this.minLineNumber = line_number;
|
|
283
|
+
}
|
|
284
|
+
this.maxLineNumber = Math.max(this.maxLineNumber, line_number);
|
|
285
|
+
|
|
286
|
+
// Add to new lines array for clusterize
|
|
287
|
+
newLines.push(html);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Append new lines to clusterize
|
|
291
|
+
if (newLines.length > 0) {
|
|
292
|
+
this.clusterize.append(newLines);
|
|
293
|
+
this.#updateLineRangeDisplay();
|
|
294
|
+
this.scroll();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Update stop button visibility after processing lines
|
|
298
|
+
this.updateStopButtonVisibility();
|
|
299
|
+
|
|
300
|
+
} catch (error) {
|
|
301
|
+
console.error('Error handling log lines:', error);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#handleMessage(message) {
|
|
306
|
+
this.#hideMessage();
|
|
307
|
+
if (message === '') {
|
|
308
|
+
this.messageTarget.innerHTML = "";
|
|
309
|
+
} else {
|
|
310
|
+
const loadingIcon = message.endsWith('...') ? '<span class="onlylogs-spin-animation">⟳</span>' : '';
|
|
311
|
+
this.messageTarget.innerHTML = loadingIcon + message;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#handleFinish(message) {
|
|
316
|
+
// Display the finish message without loading icon
|
|
317
|
+
this.messageTarget.innerHTML = message;
|
|
318
|
+
|
|
319
|
+
// Mark search as finished
|
|
320
|
+
this.isSearchFinished = true;
|
|
321
|
+
|
|
322
|
+
// Update stop button visibility (should hide it)
|
|
323
|
+
this.updateStopButtonVisibility();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#handleError(message) {
|
|
327
|
+
// Display error message with error styling
|
|
328
|
+
this.messageTarget.innerHTML = `<span class="error-message">❌ ${message}</span>`;
|
|
329
|
+
|
|
330
|
+
// Mark search as finished
|
|
331
|
+
this.isSearchFinished = true;
|
|
332
|
+
|
|
333
|
+
// Update stop button visibility (should hide it)
|
|
334
|
+
this.updateStopButtonVisibility();
|
|
335
|
+
|
|
336
|
+
// Stop the watcher
|
|
337
|
+
this.stop();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#hideMessage() {
|
|
341
|
+
this.messageTarget.innerHTML = '';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#updateLineRangeDisplay() {
|
|
345
|
+
const resultsCount = this.clusterize.getRowsAmount();
|
|
346
|
+
let lineRangeText;
|
|
347
|
+
|
|
348
|
+
if (this.minLineNumber === null || this.maxLineNumber === 0) {
|
|
349
|
+
lineRangeText = `No lines`;
|
|
350
|
+
} else if (this.minLineNumber === this.maxLineNumber) {
|
|
351
|
+
lineRangeText = `Line ${this.#formatNumber(this.minLineNumber)}`;
|
|
352
|
+
} else {
|
|
353
|
+
lineRangeText = `Lines ${this.#formatNumber(this.minLineNumber)} - ${this.#formatNumber(this.maxLineNumber)}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
this.lineRangeTarget.textContent = `${lineRangeText} | Results: ${this.#formatNumber(resultsCount)}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
#formatNumber(number) {
|
|
360
|
+
return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#updateWebsocketStatus(status) {
|
|
364
|
+
if (!this.hasWebsocketStatusTarget) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const statusElement = this.websocketStatusTarget;
|
|
369
|
+
statusElement.className = `websocket-status websocket-status--${status}`;
|
|
370
|
+
|
|
371
|
+
switch (status) {
|
|
372
|
+
case 'connected':
|
|
373
|
+
statusElement.innerHTML = '🟢';
|
|
374
|
+
statusElement.title = 'WebSocket Connected';
|
|
375
|
+
break;
|
|
376
|
+
case 'disconnected':
|
|
377
|
+
statusElement.innerHTML = '🔴';
|
|
378
|
+
statusElement.title = 'WebSocket Disconnected';
|
|
379
|
+
break;
|
|
380
|
+
case 'rejected':
|
|
381
|
+
statusElement.innerHTML = '🟡';
|
|
382
|
+
statusElement.title = 'WebSocket Connection Rejected';
|
|
383
|
+
break;
|
|
384
|
+
default:
|
|
385
|
+
statusElement.innerHTML = '⚪';
|
|
386
|
+
statusElement.title = 'WebSocket Status Unknown';
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
getStatus() {
|
|
391
|
+
return {
|
|
392
|
+
isRunning: this.isRunning,
|
|
393
|
+
filePath: this.filePathValue,
|
|
394
|
+
cursorPosition: this.cursorPositionValue,
|
|
395
|
+
lineCount: this.clusterize.getRowsAmount(),
|
|
396
|
+
maxLineNumber: this.maxLineNumber,
|
|
397
|
+
connected: this.subscription && this.subscription.identifier
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#initializeClusterize() {
|
|
402
|
+
this.clusterize = new window.Clusterize({
|
|
403
|
+
scrollId: 'scrollArea',
|
|
404
|
+
contentId: 'contentArea',
|
|
405
|
+
rows: [],
|
|
406
|
+
tag: 'pre',
|
|
407
|
+
rows_in_block: 50,
|
|
408
|
+
blocks_in_cluster: 4,
|
|
409
|
+
show_no_data_row: false,
|
|
410
|
+
no_data_text: 'No log lines available',
|
|
411
|
+
no_data_class: 'clusterize-no-data',
|
|
412
|
+
keep_parity: true,
|
|
413
|
+
callbacks: {
|
|
414
|
+
clusterWillChange: () => {
|
|
415
|
+
// Optional: handle cluster change
|
|
416
|
+
},
|
|
417
|
+
clusterChanged: () => {
|
|
418
|
+
// Optional: handle after cluster change
|
|
419
|
+
},
|
|
420
|
+
scrollingProgress: (progress) => {
|
|
421
|
+
// Optional: handle scrolling progress
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
#reinitializeClusterize() {
|
|
428
|
+
this.clusterize.destroy();
|
|
429
|
+
this.clusterize = null;
|
|
430
|
+
this.#initializeClusterize();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class TextSelectionController extends Controller {
|
|
4
|
+
static targets = ["button", "filterInput", "logLines", "regexpMode"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this.boundHandleTextSelection = this.handleTextSelection.bind(this)
|
|
8
|
+
this.boundHandleDocumentClick = this.handleDocumentClick.bind(this)
|
|
9
|
+
this.boundHandleSelectionChange = this.handleSelectionChange.bind(this)
|
|
10
|
+
|
|
11
|
+
// Only listen for text selection on the log lines, not the toolbar
|
|
12
|
+
this.logLinesTarget.addEventListener('mouseup', this.boundHandleTextSelection)
|
|
13
|
+
document.addEventListener('click', this.boundHandleDocumentClick)
|
|
14
|
+
document.addEventListener('selectionchange', this.boundHandleSelectionChange)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
disconnect() {
|
|
18
|
+
this.logLinesTarget.removeEventListener('mouseup', this.boundHandleTextSelection)
|
|
19
|
+
document.removeEventListener('click', this.boundHandleDocumentClick)
|
|
20
|
+
document.removeEventListener('selectionchange', this.boundHandleSelectionChange)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
handleTextSelection(event) {
|
|
24
|
+
const selection = window.getSelection()
|
|
25
|
+
const selectedText = selection.toString().trim()
|
|
26
|
+
|
|
27
|
+
// Check if there's actually a text selection
|
|
28
|
+
if (selectedText.length > 0 && selection.rangeCount > 0) {
|
|
29
|
+
this.showButton(event, selectedText)
|
|
30
|
+
} else {
|
|
31
|
+
this.hideButton()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
showButton(event, selectedText) {
|
|
36
|
+
if (!this.buttonTarget) {
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const containerRect = this.element.getBoundingClientRect()
|
|
41
|
+
|
|
42
|
+
// Position button below mouse cursor
|
|
43
|
+
const left = event.clientX - containerRect.left - 40 // Roughly center the button
|
|
44
|
+
const top = event.clientY - containerRect.top + 10
|
|
45
|
+
|
|
46
|
+
this.buttonTarget.style.left = Math.max(0, left) + 'px'
|
|
47
|
+
this.buttonTarget.style.top = Math.max(0, top) + 'px'
|
|
48
|
+
this.buttonTarget.style.display = 'block'
|
|
49
|
+
|
|
50
|
+
// Store selected text for search
|
|
51
|
+
this.selectedText = selectedText
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
hideButton() {
|
|
55
|
+
this.buttonTarget.style.display = 'none'
|
|
56
|
+
this.selectedText = null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
handleDocumentClick(event) {
|
|
60
|
+
// Only hide if clicking outside the button and outside the log lines container
|
|
61
|
+
if (!this.buttonTarget.contains(event.target) && !this.logLinesTarget.contains(event.target)) {
|
|
62
|
+
this.hideButton()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
handleSelectionChange() {
|
|
67
|
+
// Check if the current selection is still valid
|
|
68
|
+
const selection = window.getSelection()
|
|
69
|
+
const selectedText = selection.toString().trim()
|
|
70
|
+
|
|
71
|
+
// If no text is selected, hide the button
|
|
72
|
+
if (selectedText.length === 0 || selection.rangeCount === 0) {
|
|
73
|
+
this.hideButton()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
searchSelectedText() {
|
|
78
|
+
if (this.selectedText) {
|
|
79
|
+
if (this.regexpModeTarget.checked) {
|
|
80
|
+
this.regexpModeTarget.checked = false
|
|
81
|
+
this.regexpModeTarget.dispatchEvent(new Event('change', { bubbles: true }))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.filterInputTarget.value = this.selectedText
|
|
85
|
+
this.filterInputTarget.dispatchEvent(new Event('input', { bubbles: true }))
|
|
86
|
+
this.hideButton()
|
|
87
|
+
window.getSelection().removeAllRanges()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|