solarviewer 1.0.2__py3-none-any.whl

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 (82) hide show
  1. solar_radio_image_viewer/__init__.py +12 -0
  2. solar_radio_image_viewer/assets/add_tab_default.png +0 -0
  3. solar_radio_image_viewer/assets/add_tab_default_light.png +0 -0
  4. solar_radio_image_viewer/assets/add_tab_hover.png +0 -0
  5. solar_radio_image_viewer/assets/add_tab_hover_light.png +0 -0
  6. solar_radio_image_viewer/assets/browse.png +0 -0
  7. solar_radio_image_viewer/assets/browse_light.png +0 -0
  8. solar_radio_image_viewer/assets/close_tab_default.png +0 -0
  9. solar_radio_image_viewer/assets/close_tab_default_light.png +0 -0
  10. solar_radio_image_viewer/assets/close_tab_hover.png +0 -0
  11. solar_radio_image_viewer/assets/close_tab_hover_light.png +0 -0
  12. solar_radio_image_viewer/assets/ellipse_selection.png +0 -0
  13. solar_radio_image_viewer/assets/ellipse_selection_light.png +0 -0
  14. solar_radio_image_viewer/assets/icons8-ellipse-90.png +0 -0
  15. solar_radio_image_viewer/assets/icons8-ellipse-90_light.png +0 -0
  16. solar_radio_image_viewer/assets/icons8-info-90.png +0 -0
  17. solar_radio_image_viewer/assets/icons8-info-90_light.png +0 -0
  18. solar_radio_image_viewer/assets/profile.png +0 -0
  19. solar_radio_image_viewer/assets/profile_light.png +0 -0
  20. solar_radio_image_viewer/assets/rectangle_selection.png +0 -0
  21. solar_radio_image_viewer/assets/rectangle_selection_light.png +0 -0
  22. solar_radio_image_viewer/assets/reset.png +0 -0
  23. solar_radio_image_viewer/assets/reset_light.png +0 -0
  24. solar_radio_image_viewer/assets/ruler.png +0 -0
  25. solar_radio_image_viewer/assets/ruler_light.png +0 -0
  26. solar_radio_image_viewer/assets/search.png +0 -0
  27. solar_radio_image_viewer/assets/search_light.png +0 -0
  28. solar_radio_image_viewer/assets/settings.png +0 -0
  29. solar_radio_image_viewer/assets/settings_light.png +0 -0
  30. solar_radio_image_viewer/assets/splash.fits +0 -0
  31. solar_radio_image_viewer/assets/zoom_60arcmin.png +0 -0
  32. solar_radio_image_viewer/assets/zoom_60arcmin_light.png +0 -0
  33. solar_radio_image_viewer/assets/zoom_in.png +0 -0
  34. solar_radio_image_viewer/assets/zoom_in_light.png +0 -0
  35. solar_radio_image_viewer/assets/zoom_out.png +0 -0
  36. solar_radio_image_viewer/assets/zoom_out_light.png +0 -0
  37. solar_radio_image_viewer/create_video.py +1345 -0
  38. solar_radio_image_viewer/dialogs.py +2665 -0
  39. solar_radio_image_viewer/from_simpl/__init__.py +184 -0
  40. solar_radio_image_viewer/from_simpl/caltable_visualizer.py +1001 -0
  41. solar_radio_image_viewer/from_simpl/dynamic_spectra_dialog.py +332 -0
  42. solar_radio_image_viewer/from_simpl/make_dynamic_spectra.py +351 -0
  43. solar_radio_image_viewer/from_simpl/pipeline_logger_gui.py +1232 -0
  44. solar_radio_image_viewer/from_simpl/simpl_theme.py +352 -0
  45. solar_radio_image_viewer/from_simpl/utils.py +984 -0
  46. solar_radio_image_viewer/from_simpl/view_dynamic_spectra_GUI.py +1975 -0
  47. solar_radio_image_viewer/helioprojective.py +1916 -0
  48. solar_radio_image_viewer/helioprojective_viewer.py +817 -0
  49. solar_radio_image_viewer/helioviewer_browser.py +1514 -0
  50. solar_radio_image_viewer/main.py +148 -0
  51. solar_radio_image_viewer/move_phasecenter.py +1269 -0
  52. solar_radio_image_viewer/napari_viewer.py +368 -0
  53. solar_radio_image_viewer/noaa_events/__init__.py +32 -0
  54. solar_radio_image_viewer/noaa_events/noaa_events.py +430 -0
  55. solar_radio_image_viewer/noaa_events/noaa_events_gui.py +1922 -0
  56. solar_radio_image_viewer/norms.py +293 -0
  57. solar_radio_image_viewer/radio_data_downloader/__init__.py +25 -0
  58. solar_radio_image_viewer/radio_data_downloader/radio_data_downloader.py +756 -0
  59. solar_radio_image_viewer/radio_data_downloader/radio_data_downloader_gui.py +528 -0
  60. solar_radio_image_viewer/searchable_combobox.py +220 -0
  61. solar_radio_image_viewer/solar_context/__init__.py +41 -0
  62. solar_radio_image_viewer/solar_context/active_regions.py +371 -0
  63. solar_radio_image_viewer/solar_context/cme_alerts.py +234 -0
  64. solar_radio_image_viewer/solar_context/context_images.py +297 -0
  65. solar_radio_image_viewer/solar_context/realtime_data.py +528 -0
  66. solar_radio_image_viewer/solar_data_downloader/__init__.py +35 -0
  67. solar_radio_image_viewer/solar_data_downloader/solar_data_downloader.py +1667 -0
  68. solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_cli.py +901 -0
  69. solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_gui.py +1210 -0
  70. solar_radio_image_viewer/styles.py +643 -0
  71. solar_radio_image_viewer/utils/__init__.py +32 -0
  72. solar_radio_image_viewer/utils/rate_limiter.py +255 -0
  73. solar_radio_image_viewer/utils.py +952 -0
  74. solar_radio_image_viewer/video_dialog.py +2629 -0
  75. solar_radio_image_viewer/video_utils.py +656 -0
  76. solar_radio_image_viewer/viewer.py +11174 -0
  77. solarviewer-1.0.2.dist-info/METADATA +343 -0
  78. solarviewer-1.0.2.dist-info/RECORD +82 -0
  79. solarviewer-1.0.2.dist-info/WHEEL +5 -0
  80. solarviewer-1.0.2.dist-info/entry_points.txt +8 -0
  81. solarviewer-1.0.2.dist-info/licenses/LICENSE +21 -0
  82. solarviewer-1.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1232 @@
1
+ """
2
+ Pipeline Logger GUI
3
+ ==================
4
+
5
+ A PyQt5-based graphical user interface for displaying and monitoring logs
6
+ from the LOFAR Solar Imaging Pipeline.
7
+
8
+ Features:
9
+ - Real-time log updates
10
+ - Filtering by log level
11
+ - Search functionality
12
+ - Color-coded log entries by severity
13
+ - Auto-scrolling with pause option
14
+ - Log file viewing and management
15
+
16
+ This GUI can be used while the pipeline is running to monitor progress,
17
+ or after a pipeline run to analyze logs.
18
+
19
+ Author: Soham Dey
20
+ Date: April 2025
21
+ """
22
+
23
+ import os
24
+ import sys
25
+ import time
26
+ import logging
27
+ from queue import Queue, Empty
28
+ from datetime import datetime
29
+ import threading
30
+
31
+ from PyQt5.QtWidgets import (
32
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
33
+ QTableWidget, QTableWidgetItem, QHeaderView, QComboBox,
34
+ QPushButton, QLineEdit, QLabel, QCheckBox, QSplitter,
35
+ QTextEdit, QStatusBar, QAction, QFileDialog, QStyle,
36
+ QStyledItemDelegate, QMenu, QToolBar, QAbstractItemView,
37
+ QMessageBox, QTabWidget, QGroupBox, QRadioButton, QGridLayout,
38
+ QShortcut, QDialog
39
+ )
40
+ from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer, QSize, QPoint, QRect, QSortFilterProxyModel
41
+ from PyQt5.QtGui import QIcon, QColor, QTextCursor, QFont, QPalette, QTextCharFormat, QBrush, QKeySequence
42
+
43
+ # Local LogRecord class - compatible with any log format
44
+ class LogRecord:
45
+ """Simple log record class for storing parsed log entries."""
46
+ def __init__(self, level, name, message, timestamp=None):
47
+ self.level = level
48
+ self.name = name
49
+ self.message = message
50
+ self.timestamp = timestamp or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
51
+
52
+
53
+
54
+ class LogMonitorThread(QThread):
55
+ """Thread for monitoring log queue and emitting signals when new logs arrive."""
56
+ log_received = pyqtSignal(object) # Signal emitted when new log is received
57
+
58
+ def __init__(self, log_queue, parent=None, log_file=None):
59
+ super().__init__(parent)
60
+ self.log_queue = log_queue
61
+ self.log_file = log_file
62
+ self.running = True
63
+ self.last_file_position = 0
64
+ self.last_file_check = 0
65
+ self.file_check_interval = 0.5 # Check file every 0.5 seconds
66
+
67
+ def run(self):
68
+ """Main thread loop to check for new logs."""
69
+ while self.running:
70
+ try:
71
+ # Get log record from queue with timeout
72
+ log_record = self.log_queue.get(block=True, timeout=0.1)
73
+ self.log_received.emit(log_record)
74
+ except Empty:
75
+ # No logs in queue, check if we should monitor file directly
76
+ current_time = time.time()
77
+ if self.log_file and os.path.exists(self.log_file) and \
78
+ (current_time - self.last_file_check) >= self.file_check_interval:
79
+ self.last_file_check = current_time
80
+ self._check_log_file()
81
+
82
+ # Small sleep to reduce CPU usage
83
+ time.sleep(0.01)
84
+ except Exception as e:
85
+ print(f"Error in log monitor thread: {e}")
86
+
87
+ def _check_log_file(self):
88
+ """Check if the log file has changed and process new entries."""
89
+ try:
90
+ # Get the file size
91
+ file_size = os.path.getsize(self.log_file)
92
+
93
+ # If file size has increased, read the new content
94
+ if file_size > self.last_file_position:
95
+ with open(self.log_file, 'r') as f:
96
+ # Move to the last position we read
97
+ f.seek(self.last_file_position)
98
+
99
+ # Read new lines
100
+ new_lines = f.readlines()
101
+
102
+ # Update position
103
+ self.last_file_position = file_size
104
+
105
+ # Process each new line
106
+ for line in new_lines:
107
+ try:
108
+ # Parse log line - assumes format: TIMESTAMP - LEVEL - NAME - MESSAGE
109
+ parts = line.strip().split(' - ', 3)
110
+ if len(parts) >= 4:
111
+ timestamp, level, name, message = parts
112
+ log_record = LogRecord(level, name, message, timestamp)
113
+ self.log_received.emit(log_record)
114
+ except Exception as e:
115
+ print(f"Error parsing log line: {e}")
116
+ except Exception as e:
117
+ print(f"Error checking log file: {e}")
118
+
119
+ def set_log_file(self, log_file):
120
+ """Set the log file to monitor."""
121
+ self.log_file = log_file
122
+ if os.path.exists(log_file):
123
+ self.last_file_position = os.path.getsize(log_file)
124
+ else:
125
+ self.last_file_position = 0
126
+
127
+ def stop(self):
128
+ """Stop the thread."""
129
+ self.running = False
130
+ self.wait()
131
+
132
+
133
+ class LogLevelDelegate(QStyledItemDelegate):
134
+ """Custom delegate for rendering log level cells with background colors."""
135
+
136
+ # Theme-aware level colors
137
+ LEVEL_COLORS_DARK = {
138
+ "DEBUG": (QColor(80, 80, 100), QColor(200, 200, 200)), # Muted blue bg, light text
139
+ "INFO": (QColor(50, 80, 50), QColor(180, 255, 180)), # Dark green bg, light green text
140
+ "WARNING": (QColor(120, 100, 40), QColor(255, 240, 100)), # Dark yellow bg, bright yellow text
141
+ "ERROR": (QColor(120, 50, 50), QColor(255, 150, 150)), # Dark red bg, light red text
142
+ "CRITICAL": (QColor(150, 40, 40), QColor(255, 100, 100)) # Darker red bg, bright red text
143
+ }
144
+
145
+ LEVEL_COLORS_LIGHT = {
146
+ "DEBUG": (QColor(200, 200, 220), QColor(60, 60, 80)), # Light gray bg, dark text
147
+ "INFO": (QColor(200, 240, 200), QColor(20, 80, 20)), # Light green bg, dark green text
148
+ "WARNING": (QColor(255, 240, 180), QColor(120, 90, 0)), # Light yellow bg, dark yellow text
149
+ "ERROR": (QColor(255, 200, 200), QColor(150, 30, 30)), # Light red bg, dark red text
150
+ "CRITICAL": (QColor(255, 150, 150), QColor(120, 0, 0)) # Red bg, dark red text
151
+ }
152
+
153
+ def __init__(self, theme="dark", parent=None):
154
+ super().__init__(parent)
155
+ self.theme = theme
156
+ self.level_colors = self.LEVEL_COLORS_DARK if theme == "dark" else self.LEVEL_COLORS_LIGHT
157
+
158
+ def set_theme(self, theme):
159
+ """Update the theme and colors."""
160
+ self.theme = theme
161
+ self.level_colors = self.LEVEL_COLORS_DARK if theme == "dark" else self.LEVEL_COLORS_LIGHT
162
+
163
+ def paint(self, painter, option, index):
164
+ """Custom painting for log level cells."""
165
+ level = index.data()
166
+ if level in self.level_colors:
167
+ bg_color, text_color = self.level_colors[level]
168
+
169
+ # Fill background with level color
170
+ painter.fillRect(option.rect, bg_color)
171
+
172
+ # Set up rect for text with padding
173
+ text_rect = QRect(option.rect)
174
+ text_rect.setLeft(text_rect.left() + 4)
175
+
176
+ # Draw text with appropriate color
177
+ painter.setPen(text_color)
178
+ painter.drawText(text_rect, Qt.AlignVCenter, level)
179
+ else:
180
+ # Fall back to default for unknown levels
181
+ super().paint(painter, option, index)
182
+
183
+
184
+ class MessageDelegate(QStyledItemDelegate):
185
+ """Custom delegate for syntax highlighting using HTML and QTextDocument."""
186
+
187
+ def __init__(self, theme="dark", parent=None):
188
+ super().__init__(parent)
189
+ self.theme = theme
190
+ import re
191
+
192
+ # Professional syntax highlighting patterns (priority-ordered - first match wins)
193
+ # More specific patterns come first to avoid false positives
194
+ self.patterns = [
195
+ # 1. Quoted strings FIRST - prevent highlighting inside quotes
196
+ (re.compile(r'("[^"]*"|\'[^\']*\')'), 'string'),
197
+
198
+ # 2. Log levels / status keywords (very specific)
199
+ (re.compile(r'\b(SUCCESS|FAILED|FAILURE|ERROR|WARNING|CRITICAL|INFO|DEBUG|COMPLETE|COMPLETED|DONE|OK|PASS|PASSED)\b', re.IGNORECASE), 'keyword'),
200
+
201
+ # 3. Timestamps - multiple formats
202
+ # ISO format: 2025-06-13T10:53:47.579Z
203
+ (re.compile(r'\b(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\b'), 'timestamp'),
204
+ # CASACORE format: 25-Aug-2014/12:42:00.268
205
+ (re.compile(r'\b(\d{1,2}-[A-Za-z]{3}-\d{4}/\d{2}:\d{2}:\d{2}(?:\.\d+)?)\b'), 'timestamp'),
206
+ # Time only: 10:53:47.579
207
+ (re.compile(r'\b(\d{2}:\d{2}:\d{2}(?:\.\d+)?)\b'), 'timestamp'),
208
+ # Date only: 2025-06-13 or 25-Aug-2014
209
+ (re.compile(r'\b(\d{4}-\d{2}-\d{2}|\d{1,2}-[A-Za-z]{3}-\d{4})\b'), 'timestamp'),
210
+
211
+ # 4. File paths (require depth or known extensions)
212
+ (re.compile(r'(/[\w.-]+(?:/[\w.-]+)+|/[\w.-]+\.(?:ms|MS|fits|FITS|log|txt|py|sh|conf|json|yaml|yml|csv|h5|hdf5))'), 'path'),
213
+
214
+ # 5. LOFAR-specific: Station names with antenna type
215
+ (re.compile(r'\b([CR]S\d{3}(?:HBA\d?|LBA)?)\b', re.IGNORECASE), 'station'),
216
+
217
+ # 6. LOFAR-specific: Subbands (SB000-SB999)
218
+ (re.compile(r'\b(SB\d{1,3})\b', re.IGNORECASE), 'subband'),
219
+
220
+ # 7. IP addresses and ports
221
+ (re.compile(r'\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?)\b'), 'number'),
222
+
223
+ # 8. Numbers with units - SHORT forms (space optional)
224
+ # Note: Order matters - 'ms' before 's' so milliseconds matches first
225
+ (re.compile(r'\b(\d+(?:\.\d+)?\s*(?:MHz|kHz|Hz|GHz|THz|ms|μs|us|ns|s|h|min|m|km|Jy|mJy|μJy|uJy|dB|dBm|MB|GB|TB|KB|px|deg|rad|arcsec|arcmin|λ|%|Mλ|kλ))\b', re.IGNORECASE), 'number'),
226
+
227
+ # 9. Numbers with units - LONG forms (time)
228
+ (re.compile(r'\b(\d+(?:\.\d+)?\s*(?:seconds?|minutes?|hours?|days?|milliseconds?|microseconds?|nanoseconds?))\b', re.IGNORECASE), 'number'),
229
+
230
+ # 10. Numbers with units - LONG forms (frequency/data)
231
+ (re.compile(r'\b(\d+(?:\.\d+)?\s*(?:hertz|megahertz|gigahertz|kilohertz|bytes?|kilobytes?|megabytes?|gigabytes?|terabytes?))\b', re.IGNORECASE), 'number'),
232
+
233
+ # 11. Numbers with units - LONG forms (spatial)
234
+ (re.compile(r'\b(\d+(?:\.\d+)?\s*(?:meters?|kilometres?|kilometers?|pixels?|degrees?|radians?|wavelengths?))\b', re.IGNORECASE), 'number'),
235
+
236
+ # 12. Scientific notation (1.23e-4)
237
+ (re.compile(r'\b(\d+\.\d+[eE][+-]?\d+)\b'), 'number'),
238
+
239
+ # 13. Percentages and ratios
240
+ (re.compile(r'\b(\d+(?:\.\d+)?%|\d+/\d+)\b'), 'number'),
241
+
242
+ # 14. UUID/hash patterns (common in logs)
243
+ (re.compile(r'\b([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\b', re.IGNORECASE), 'hash'),
244
+ (re.compile(r'\b([a-f0-9]{32,64})\b', re.IGNORECASE), 'hash'),
245
+
246
+ # 15. Function/method names (word followed by parentheses)
247
+ (re.compile(r'\b([a-zA-Z_]\w*)\('), 'function'),
248
+ ]
249
+ self._html_cache = {} # Cache for generated HTML - must init before _update_colors
250
+ self._update_colors()
251
+
252
+ def _update_colors(self):
253
+ """Set colors based on theme (as hex strings for HTML)."""
254
+ if self.theme == "dark":
255
+ self.colors = {
256
+ 'path': '#64b4ff', # Light blue - file paths
257
+ 'number': '#ffb464', # Bright orange - numbers with units
258
+ 'station': '#dc96ff', # Magenta/purple - LOFAR stations
259
+ 'string': '#dcdc64', # Yellow - quoted strings
260
+ 'subband': '#96dc96', # Light green - subbands
261
+ 'keyword': '#ff6b6b', # Light red - log levels/status
262
+ 'timestamp': '#78c878', # Green - timestamps
263
+ 'hash': '#a0a0a0', # Gray - UUIDs/hashes
264
+ 'function': '#64dcdc', # Cyan - function names
265
+ 'default': '#c8c8c8', # Light gray - regular text
266
+ }
267
+ else:
268
+ self.colors = {
269
+ 'path': '#0000b4', # Blue
270
+ 'number': '#b46400', # Orange
271
+ 'station': '#8c008c', # Purple
272
+ 'string': '#786400', # Dark yellow
273
+ 'subband': '#007800', # Green
274
+ 'keyword': '#c80000', # Red
275
+ 'timestamp': '#006400', # Dark green
276
+ 'hash': '#606060', # Gray
277
+ 'function': '#006464', # Dark cyan
278
+ 'default': '#1e1e1e', # Dark gray
279
+ }
280
+ self._html_cache.clear() # Clear cache on theme change
281
+
282
+ def set_theme(self, theme):
283
+ """Update the theme and colors."""
284
+ self.theme = theme
285
+ self._update_colors()
286
+
287
+ def _text_to_html(self, text):
288
+ """Convert text to HTML with syntax highlighting. Uses cache for performance."""
289
+ if text in self._html_cache:
290
+ return self._html_cache[text]
291
+
292
+ import html
293
+
294
+ # Find all matches
295
+ matches = []
296
+ for pattern, ptype in self.patterns:
297
+ for match in pattern.finditer(text):
298
+ matches.append((match.start(), match.end(), ptype))
299
+
300
+ # Sort by position and remove overlaps
301
+ matches.sort(key=lambda x: x[0])
302
+ non_overlapping = []
303
+ last_end = 0
304
+ for start, end, ptype in matches:
305
+ if start >= last_end:
306
+ non_overlapping.append((start, end, ptype))
307
+ last_end = end
308
+
309
+ # Build HTML
310
+ html_parts = []
311
+ pos = 0
312
+ for start, end, ptype in non_overlapping:
313
+ # Add unhighlighted text before match (escaped)
314
+ if start > pos:
315
+ html_parts.append(html.escape(text[pos:start]))
316
+ # Add highlighted match
317
+ color = self.colors[ptype]
318
+ html_parts.append(f'<span style="color:{color}">{html.escape(text[start:end])}</span>')
319
+ pos = end
320
+
321
+ # Add remaining text
322
+ if pos < len(text):
323
+ html_parts.append(html.escape(text[pos:]))
324
+
325
+ result = ''.join(html_parts) if html_parts else html.escape(text)
326
+
327
+ # Cache the result (limit cache size)
328
+ if len(self._html_cache) > 5000:
329
+ self._html_cache.clear()
330
+ self._html_cache[text] = result
331
+
332
+ return result
333
+
334
+ def paint(self, painter, option, index):
335
+ """Paint cell using QTextDocument for proper HTML rendering."""
336
+ from PyQt5.QtWidgets import QStyle
337
+ from PyQt5.QtGui import QTextDocument, QAbstractTextDocumentLayout
338
+
339
+ text = index.data()
340
+ if not text:
341
+ super().paint(painter, option, index)
342
+ return
343
+
344
+ # Save painter state
345
+ painter.save()
346
+
347
+ # Draw background
348
+ if option.state & QStyle.State_Selected:
349
+ painter.fillRect(option.rect, option.palette.highlight())
350
+ else:
351
+ painter.fillRect(option.rect, option.palette.base())
352
+
353
+ # Create QTextDocument with HTML
354
+ doc = QTextDocument()
355
+ doc.setDefaultFont(option.font)
356
+
357
+ # Set text color based on selection state
358
+ if option.state & QStyle.State_Selected:
359
+ # When selected, use plain text (no colors) for readability
360
+ doc.setPlainText(text)
361
+ doc.setDefaultStyleSheet(f"body {{ color: {option.palette.highlightedText().color().name()}; }}")
362
+ else:
363
+ # Normal state: use syntax-highlighted HTML
364
+ html_content = self._text_to_html(text)
365
+ doc.setHtml(f'<span style="color:{self.colors["default"]}">{html_content}</span>')
366
+
367
+ # Set up clipping
368
+ painter.setClipRect(option.rect)
369
+
370
+ # Translate to cell position with padding
371
+ painter.translate(option.rect.left() + 4, option.rect.top() + (option.rect.height() - doc.size().height()) / 2)
372
+
373
+ # Draw the document
374
+ doc.drawContents(painter)
375
+
376
+ # Restore painter state
377
+ painter.restore()
378
+
379
+ def sizeHint(self, option, index):
380
+ """Return size hint based on text width."""
381
+ from PyQt5.QtGui import QTextDocument
382
+ from PyQt5.QtCore import QSize
383
+
384
+ text = index.data()
385
+ if not text:
386
+ return super().sizeHint(option, index)
387
+
388
+ doc = QTextDocument()
389
+ doc.setDefaultFont(option.font)
390
+ doc.setPlainText(text)
391
+
392
+ return QSize(int(doc.idealWidth()) + 10, int(doc.size().height()))
393
+
394
+
395
+ class LogTableModel:
396
+ """Model for storing and managing log entries."""
397
+
398
+ COLUMNS = ["Timestamp", "Level", "Source", "Message"]
399
+
400
+ def __init__(self, table_widget, theme="dark"):
401
+ self.table = table_widget
402
+ self.theme = theme
403
+ self.logs = []
404
+ self.filtered_logs = []
405
+ self.filter_level = "DEBUG" # Show all by default
406
+ self.filter_text = ""
407
+ self.use_regex = False # Regex search mode
408
+ self._regex_pattern = None # Compiled regex pattern
409
+
410
+ # Set up table
411
+ self.setup_table()
412
+
413
+ def setup_table(self):
414
+ """Configure the table widget."""
415
+ self.table.setColumnCount(len(self.COLUMNS))
416
+ self.table.setHorizontalHeaderLabels(self.COLUMNS)
417
+
418
+ # Set stretch for message column
419
+ header = self.table.horizontalHeader()
420
+ header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # Timestamp
421
+ header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Level
422
+ header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Source
423
+ header.setSectionResizeMode(3, QHeaderView.ResizeToContents) # Message - allow scroll
424
+
425
+ # Set custom delegate for level column (theme-aware)
426
+ self.level_delegate = LogLevelDelegate(theme=self.theme)
427
+ self.table.setItemDelegateForColumn(1, self.level_delegate)
428
+
429
+ # Set custom delegate for message column (syntax highlighting)
430
+ self.message_delegate = MessageDelegate(theme=self.theme)
431
+ self.table.setItemDelegateForColumn(3, self.message_delegate)
432
+
433
+ # Enable horizontal scrolling for long messages
434
+ self.table.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
435
+
436
+ # Prevent auto-scrolling horizontally when selecting rows
437
+ self.table.setAutoScroll(False)
438
+
439
+ # Selection behavior
440
+ self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
441
+
442
+ def add_log(self, log_record):
443
+ """Add a new log record to the model."""
444
+ # Create a dict for the log to store all data
445
+ log_entry = {
446
+ "timestamp": log_record.timestamp,
447
+ "level": log_record.level,
448
+ "source": log_record.name,
449
+ "message": log_record.message
450
+ }
451
+
452
+ self.logs.append(log_entry)
453
+
454
+ # Check if this log should be displayed based on current filters
455
+ if self._matches_filter(log_entry):
456
+ self.filtered_logs.append(log_entry)
457
+ self._add_to_table(log_entry)
458
+
459
+ def _add_to_table(self, log_entry):
460
+ """Add a log entry to the table widget."""
461
+ row = self.table.rowCount()
462
+ self.table.insertRow(row)
463
+
464
+ # Add cells
465
+ self.table.setItem(row, 0, QTableWidgetItem(log_entry["timestamp"]))
466
+ self.table.setItem(row, 1, QTableWidgetItem(log_entry["level"]))
467
+ self.table.setItem(row, 2, QTableWidgetItem(log_entry["source"]))
468
+ self.table.setItem(row, 3, QTableWidgetItem(log_entry["message"]))
469
+
470
+ def clear(self):
471
+ """Clear all logs from the model and table."""
472
+ self.logs = []
473
+ self.filtered_logs = []
474
+ self.table.setRowCount(0)
475
+
476
+ def set_filter_level(self, level):
477
+ """Set the minimum log level filter."""
478
+ self.filter_level = level
479
+ self._apply_filters()
480
+
481
+ def set_filter_text(self, text, use_regex=False):
482
+ """Set the text filter."""
483
+ self.filter_text = text.lower()
484
+ self.use_regex = use_regex
485
+ if use_regex and text:
486
+ try:
487
+ import re
488
+ self._regex_pattern = re.compile(text, re.IGNORECASE)
489
+ except re.error:
490
+ self._regex_pattern = None
491
+ else:
492
+ self._regex_pattern = None
493
+ self._apply_filters()
494
+
495
+ def _matches_filter(self, log_entry):
496
+ """Check if a log entry matches the current filters."""
497
+ # Check level filter
498
+ level_idx = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"].index(log_entry["level"])
499
+ min_level_idx = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"].index(self.filter_level)
500
+ if level_idx < min_level_idx:
501
+ return False
502
+
503
+ # Check text filter
504
+ if self.filter_text:
505
+ if self.use_regex and self._regex_pattern:
506
+ # Regex search
507
+ if not (self._regex_pattern.search(log_entry["message"]) or
508
+ self._regex_pattern.search(log_entry["source"])):
509
+ return False
510
+ else:
511
+ # Plain text search
512
+ if not (self.filter_text in log_entry["message"].lower() or
513
+ self.filter_text in log_entry["source"].lower()):
514
+ return False
515
+
516
+ return True
517
+
518
+ def _apply_filters(self):
519
+ """Apply current filters to all logs and update the table."""
520
+ # Save current selection if any
521
+ selected_rows = [index.row() for index in self.table.selectionModel().selectedRows()]
522
+ selected_log = self.filtered_logs[selected_rows[0]] if selected_rows else None
523
+
524
+ # Clear and rebuild filtered logs
525
+ self.filtered_logs = [log for log in self.logs if self._matches_filter(log)]
526
+
527
+ # Rebuild table
528
+ self._refresh_table()
529
+
530
+ # Restore selection if possible
531
+ if selected_log and selected_log in self.filtered_logs:
532
+ new_row = self.filtered_logs.index(selected_log)
533
+ self.table.selectRow(new_row)
534
+
535
+ # Ensure visible
536
+ self.table.scrollToItem(self.table.item(new_row, 0))
537
+
538
+ def _refresh_table(self):
539
+ """Rebuild the table from current filtered_logs."""
540
+ self.table.setRowCount(0)
541
+ for log_entry in self.filtered_logs:
542
+ self._add_to_table(log_entry)
543
+
544
+ def export_logs(self, filename):
545
+ """Export logs to a CSV file."""
546
+ with open(filename, 'w') as f:
547
+ # Write header
548
+ f.write(",".join([f'"{col}"' for col in self.COLUMNS]) + "\n")
549
+
550
+ # Write data
551
+ for log in self.logs:
552
+ message = log["message"].replace('"', '""') # Escape quotes
553
+ row = [
554
+ f'"{log["timestamp"]}"',
555
+ f'"{log["level"]}"',
556
+ f'"{log["source"]}"',
557
+ f'"{message}"'
558
+ ]
559
+ f.write(",".join(row) + "\n")
560
+
561
+
562
+ class PipelineLoggerGUI(QMainWindow):
563
+ """Main window for the Pipeline Logger GUI application."""
564
+
565
+ def __init__(self, theme="dark"):
566
+ super().__init__()
567
+ self.theme = theme
568
+
569
+ # Initialize empty queue for logs
570
+ self.log_queue = Queue()
571
+
572
+ # Initialize UI
573
+ self.setWindowTitle("LOFAR Pipeline Logger")
574
+ self.setMinimumSize(800, 600)
575
+
576
+ # Create UI components
577
+ self._create_ui()
578
+
579
+ # Set up keyboard shortcuts
580
+ self._setup_shortcuts()
581
+
582
+ # Set up log monitor with no initial log file
583
+ self.log_monitor = LogMonitorThread(self.log_queue)
584
+ self.log_monitor.log_received.connect(self.on_log_received)
585
+
586
+ # Set up auto-refresh timer
587
+ self.refresh_timer = QTimer()
588
+ self.refresh_timer.timeout.connect(self._check_file_size)
589
+
590
+ # Start monitoring
591
+ self.log_monitor.start()
592
+
593
+ # Set initial status
594
+ self.status_bar.showMessage("Ready - No log file selected", 5000)
595
+
596
+ def _create_ui(self):
597
+ """Create the user interface components."""
598
+ # Create central widget
599
+ central_widget = QWidget()
600
+ self.setCentralWidget(central_widget)
601
+ main_layout = QVBoxLayout(central_widget)
602
+
603
+ # Create toolbar
604
+ self._create_toolbar()
605
+
606
+ # Create log filter controls
607
+ filter_layout = self._create_filter_controls()
608
+ main_layout.addLayout(filter_layout)
609
+
610
+ # Create log table
611
+ self.log_table = QTableWidget()
612
+ self.log_model = LogTableModel(self.log_table, theme=self.theme)
613
+ main_layout.addWidget(self.log_table)
614
+
615
+ # Context menu for table
616
+ self.log_table.setContextMenuPolicy(Qt.CustomContextMenu)
617
+ self.log_table.customContextMenuRequested.connect(self._show_context_menu)
618
+
619
+ # Create status bar
620
+ self.status_bar = QStatusBar()
621
+ self.setStatusBar(self.status_bar)
622
+ self.status_bar.showMessage("Ready")
623
+
624
+ # Auto-scroll checkbox on status bar
625
+ self.auto_scroll_check = QCheckBox("Auto-scroll")
626
+ self.auto_scroll_check.setChecked(True)
627
+ self.status_bar.addPermanentWidget(self.auto_scroll_check)
628
+
629
+ # Auto-refresh checkbox on status bar
630
+ self.auto_refresh_check = QCheckBox("Auto-refresh")
631
+ self.auto_refresh_check.setChecked(False)
632
+ self.auto_refresh_check.setToolTip("Automatically check for file changes every 2 seconds")
633
+ self.auto_refresh_check.stateChanged.connect(self._toggle_auto_refresh)
634
+ self.status_bar.addPermanentWidget(self.auto_refresh_check)
635
+
636
+ # Log count on status bar
637
+ self.log_count_label = QLabel("0 logs")
638
+ self.status_bar.addPermanentWidget(self.log_count_label)
639
+
640
+ def _create_toolbar(self):
641
+ """Create the main toolbar."""
642
+ self.toolbar = QToolBar("Main Toolbar")
643
+ self.toolbar.setIconSize(QSize(24, 24))
644
+ self.addToolBar(self.toolbar)
645
+
646
+ # File actions
647
+ self.action_open = QAction("Open Log File", self)
648
+ self.action_open.setIcon(self.style().standardIcon(QStyle.SP_DialogOpenButton))
649
+ self.action_open.triggered.connect(self._open_log_file)
650
+ self.toolbar.addAction(self.action_open)
651
+
652
+ self.action_export = QAction("Export", self)
653
+ self.action_export.setIcon(self.style().standardIcon(QStyle.SP_DialogSaveButton))
654
+ self.action_export.triggered.connect(self._export_logs)
655
+ self.toolbar.addAction(self.action_export)
656
+
657
+ self.action_refresh = QAction("Refresh", self)
658
+ self.action_refresh.setIcon(self.style().standardIcon(QStyle.SP_BrowserReload))
659
+ self.action_refresh.triggered.connect(self._refresh_monitor)
660
+ self.toolbar.addAction(self.action_refresh)
661
+
662
+ # Scroll navigation
663
+ self.toolbar.addSeparator()
664
+
665
+ self.action_scroll_top = QAction("⏫", self)
666
+ self.action_scroll_top.triggered.connect(self._scroll_to_top)
667
+ self.toolbar.addAction(self.action_scroll_top)
668
+ # Add tooltip
669
+ self.action_scroll_top.setToolTip("Scroll to top of log")
670
+
671
+ self.action_scroll_bottom = QAction("⏬", self)
672
+ self.action_scroll_bottom.triggered.connect(self._scroll_to_bottom)
673
+ self.toolbar.addAction(self.action_scroll_bottom)
674
+ # Add tooltip
675
+ self.action_scroll_bottom.setToolTip("Scroll to bottom of log")
676
+
677
+ # Error navigation
678
+ self.toolbar.addSeparator()
679
+
680
+ self.action_prev_error = QAction("◀ Error", self)
681
+ self.action_prev_error.triggered.connect(self._jump_to_prev_error)
682
+ self.toolbar.addAction(self.action_prev_error)
683
+ # Add tooltip
684
+ self.action_prev_error.setToolTip("Jump to previous error")
685
+
686
+ self.action_next_error = QAction("Error ▶", self)
687
+ self.action_next_error.triggered.connect(self._jump_to_next_error)
688
+ self.toolbar.addAction(self.action_next_error)
689
+ # Add tooltip
690
+ self.action_next_error.setToolTip("Jump to next error")
691
+
692
+ # Help button
693
+ self.toolbar.addSeparator()
694
+ self.action_help = QAction("?", self)
695
+ self.action_help.triggered.connect(self._show_help_dialog)
696
+ self.toolbar.addAction(self.action_help)
697
+
698
+ def _setup_shortcuts(self):
699
+ """Set up keyboard shortcuts."""
700
+ # File operations
701
+ QShortcut(QKeySequence("Ctrl+O"), self, self._open_log_file)
702
+ QShortcut(QKeySequence("Ctrl+E"), self, self._export_logs)
703
+ QShortcut(QKeySequence("Ctrl+Q"), self, self.close)
704
+
705
+ # Search
706
+ QShortcut(QKeySequence("Ctrl+F"), self, self._focus_search)
707
+ QShortcut(QKeySequence("Escape"), self, self._clear_filters)
708
+ QShortcut(QKeySequence("F5"), self, self._refresh_monitor)
709
+
710
+ # Scroll navigation
711
+ QShortcut(QKeySequence("Home"), self, self._scroll_to_top)
712
+ QShortcut(QKeySequence("End"), self, self._scroll_to_bottom)
713
+
714
+ # Error navigation
715
+ QShortcut(QKeySequence("Ctrl+."), self, self._jump_to_next_error)
716
+ QShortcut(QKeySequence("Ctrl+,"), self, self._jump_to_prev_error)
717
+
718
+ # Tail mode toggle
719
+ QShortcut(QKeySequence("Ctrl+T"), self, self._toggle_tail_mode)
720
+
721
+ # Help
722
+ QShortcut(QKeySequence("F1"), self, self._show_help_dialog)
723
+
724
+ def _focus_search(self):
725
+ """Focus the search input box."""
726
+ self.search_input.setFocus()
727
+ self.search_input.selectAll()
728
+
729
+ def _scroll_to_top(self):
730
+ """Scroll to the first row."""
731
+ if self.log_table.rowCount() > 0:
732
+ self.log_table.scrollToTop()
733
+ self.log_table.selectRow(0)
734
+
735
+ def _scroll_to_bottom(self):
736
+ """Scroll to the last row."""
737
+ if self.log_table.rowCount() > 0:
738
+ self.log_table.scrollToBottom()
739
+ self.log_table.selectRow(self.log_table.rowCount() - 1)
740
+
741
+ def _jump_to_next_error(self):
742
+ """Jump to the next ERROR or CRITICAL log entry."""
743
+ current_row = self.log_table.currentRow()
744
+ for i in range(current_row + 1, len(self.log_model.filtered_logs)):
745
+ if self.log_model.filtered_logs[i]['level'] in ('ERROR', 'CRITICAL'):
746
+ self.log_table.selectRow(i)
747
+ self.log_table.scrollTo(self.log_table.model().index(i, 0))
748
+ self._update_error_status(i)
749
+ return
750
+ # Wrap around to start
751
+ for i in range(0, current_row):
752
+ if self.log_model.filtered_logs[i]['level'] in ('ERROR', 'CRITICAL'):
753
+ self.log_table.selectRow(i)
754
+ self.log_table.scrollTo(self.log_table.model().index(i, 0))
755
+ self._update_error_status(i)
756
+ return
757
+ self.status_bar.showMessage("No errors found", 2000)
758
+
759
+ def _jump_to_prev_error(self):
760
+ """Jump to the previous ERROR or CRITICAL log entry."""
761
+ current_row = self.log_table.currentRow()
762
+ for i in range(current_row - 1, -1, -1):
763
+ if self.log_model.filtered_logs[i]['level'] in ('ERROR', 'CRITICAL'):
764
+ self.log_table.selectRow(i)
765
+ self.log_table.scrollTo(self.log_table.model().index(i, 0))
766
+ self._update_error_status(i)
767
+ return
768
+ # Wrap around to end
769
+ for i in range(len(self.log_model.filtered_logs) - 1, current_row, -1):
770
+ if self.log_model.filtered_logs[i]['level'] in ('ERROR', 'CRITICAL'):
771
+ self.log_table.selectRow(i)
772
+ self.log_table.scrollTo(self.log_table.model().index(i, 0))
773
+ self._update_error_status(i)
774
+ return
775
+ self.status_bar.showMessage("No errors found", 2000)
776
+
777
+ def _update_error_status(self, current_idx):
778
+ """Update status bar with error position."""
779
+ error_indices = [i for i, log in enumerate(self.log_model.filtered_logs)
780
+ if log['level'] in ('ERROR', 'CRITICAL')]
781
+ if error_indices:
782
+ pos = error_indices.index(current_idx) + 1
783
+ total = len(error_indices)
784
+ self.status_bar.showMessage(f"Error {pos} of {total}", 3000)
785
+
786
+ def _toggle_tail_mode(self):
787
+ """Toggle tail mode on/off."""
788
+ self.tail_check.setChecked(not self.tail_check.isChecked())
789
+
790
+ def _on_tail_mode_changed(self, state):
791
+ """Handle tail mode checkbox change."""
792
+ self.tail_lines_spin.setEnabled(state == Qt.Checked)
793
+ if state == Qt.Checked:
794
+ self._apply_tail_filter()
795
+ else:
796
+ # Restore full view
797
+ self.log_model._apply_filters()
798
+
799
+ def _apply_tail_filter(self):
800
+ """Apply tail mode filtering to show only last N lines."""
801
+ if self.tail_check.isChecked():
802
+ n = self.tail_lines_spin.value()
803
+ # Get the last N filtered logs
804
+ self.log_model.filtered_logs = self.log_model.filtered_logs[-n:]
805
+ self.log_model._refresh_table()
806
+ self.log_table.scrollToBottom()
807
+ self._update_status()
808
+
809
+ def _show_help_dialog(self):
810
+ """Show help dialog with keyboard shortcuts."""
811
+ dialog = QDialog(self)
812
+ dialog.setWindowTitle("Help - Pipeline Logger")
813
+ dialog.resize(500, 500)
814
+
815
+ layout = QVBoxLayout(dialog)
816
+
817
+ text = QTextEdit()
818
+ text.setReadOnly(True)
819
+ text.setHtml("""
820
+ <h2>LOFAR Pipeline Log Viewer</h2>
821
+ <p>View and analyze pipeline log files.</p>
822
+
823
+ <h3>Features</h3>
824
+ <ul>
825
+ <li>Real-time log monitoring</li>
826
+ <li>Filter by log level</li>
827
+ <li>Search with text or regex</li>
828
+ <li>Tail mode (show last N lines)</li>
829
+ <li>Jump to next/previous error</li>
830
+ <li>Export logs to CSV</li>
831
+ </ul>
832
+
833
+ <hr>
834
+ <h2>Keyboard Shortcuts</h2>
835
+
836
+ <h3>File Operations</h3>
837
+ <table>
838
+ <tr><td width="100"><b>Ctrl+O</b></td><td>Open log file</td></tr>
839
+ <tr><td><b>Ctrl+E</b></td><td>Export logs to CSV</td></tr>
840
+ <tr><td><b>Ctrl+Q</b></td><td>Quit application</td></tr>
841
+ <tr><td><b>F5</b></td><td>Refresh log file</td></tr>
842
+ </table>
843
+
844
+ <h3>Navigation</h3>
845
+ <table>
846
+ <tr><td width="100"><b>Home</b></td><td>Scroll to top</td></tr>
847
+ <tr><td><b>End</b></td><td>Scroll to bottom</td></tr>
848
+ <tr><td><b>PageUp</b></td><td>Page up</td></tr>
849
+ <tr><td><b>PageDown</b></td><td>Page down</td></tr>
850
+ <tr><td><b>Ctrl+.</b></td><td>Jump to next error</td></tr>
851
+ <tr><td><b>Ctrl+,</b></td><td>Jump to previous error</td></tr>
852
+ </table>
853
+
854
+ <h3>Search & Filters</h3>
855
+ <table>
856
+ <tr><td width="100"><b>Ctrl+F</b></td><td>Focus search box</td></tr>
857
+ <tr><td><b>Escape</b></td><td>Clear filters</td></tr>
858
+ <tr><td><b>Ctrl+T</b></td><td>Toggle tail mode</td></tr>
859
+ </table>
860
+
861
+ <h3>Help</h3>
862
+ <table>
863
+ <tr><td width="100"><b>F1</b></td><td>Show this dialog</td></tr>
864
+ </table>
865
+
866
+ <hr>
867
+ <p><i>Part of Solar Radio Image Viewer LOFAR Tools</i></p>
868
+ """)
869
+ layout.addWidget(text)
870
+
871
+ dialog.exec_()
872
+
873
+ def _create_filter_controls(self):
874
+ """Create controls for filtering logs."""
875
+ filter_layout = QHBoxLayout()
876
+
877
+ # Log level filter
878
+ level_label = QLabel("Min Level:")
879
+ self.level_combo = QComboBox()
880
+ self.level_combo.addItems(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"])
881
+ self.level_combo.setCurrentText("INFO") # Default to INFO
882
+ self.level_combo.currentTextChanged.connect(self._on_filter_changed)
883
+
884
+ filter_layout.addWidget(level_label)
885
+ filter_layout.addWidget(self.level_combo)
886
+
887
+ # Search filter
888
+ search_label = QLabel("Search:")
889
+ self.search_input = QLineEdit()
890
+ self.search_input.setPlaceholderText("Search in logs...")
891
+ self.search_input.textChanged.connect(self._on_search_changed)
892
+
893
+ filter_layout.addWidget(search_label)
894
+ filter_layout.addWidget(self.search_input)
895
+
896
+ # Regex toggle
897
+ self.regex_check = QCheckBox("Regex")
898
+ self.regex_check.setChecked(False)
899
+ self.regex_check.stateChanged.connect(self._on_regex_changed)
900
+ filter_layout.addWidget(self.regex_check)
901
+
902
+ # Tail mode
903
+ filter_layout.addWidget(QLabel("|"))
904
+ self.tail_check = QCheckBox("Tail")
905
+ self.tail_check.setChecked(False)
906
+ self.tail_check.stateChanged.connect(self._on_tail_mode_changed)
907
+ filter_layout.addWidget(self.tail_check)
908
+
909
+ from PyQt5.QtWidgets import QSpinBox
910
+ self.tail_lines_spin = QSpinBox()
911
+ self.tail_lines_spin.setRange(10, 10000)
912
+ self.tail_lines_spin.setValue(100)
913
+ self.tail_lines_spin.setSuffix(" lines")
914
+ self.tail_lines_spin.setEnabled(False)
915
+ self.tail_lines_spin.valueChanged.connect(self._apply_tail_filter)
916
+ filter_layout.addWidget(self.tail_lines_spin)
917
+
918
+ # Clear button
919
+ clear_button = QPushButton("Clear")
920
+ clear_button.clicked.connect(self._clear_filters)
921
+ filter_layout.addWidget(clear_button)
922
+
923
+ return filter_layout
924
+
925
+ def _on_filter_changed(self, level):
926
+ """Handler for when the level filter changes."""
927
+ self.log_model.set_filter_level(level)
928
+ self._update_status()
929
+
930
+ def _on_search_changed(self, text=None):
931
+ """Handler for when the search text changes."""
932
+ # Always get text from search input (ignore signal argument which may be int)
933
+ text = self.search_input.text()
934
+ use_regex = self.regex_check.isChecked()
935
+ self.log_model.set_filter_text(text, use_regex=use_regex)
936
+ self._update_status()
937
+
938
+ def _on_regex_changed(self, state):
939
+ """Handler for when the regex checkbox changes."""
940
+ self._on_search_changed()
941
+
942
+ def _clear_filters(self):
943
+ """Clear all filters."""
944
+ self.level_combo.setCurrentText("DEBUG")
945
+ self.search_input.clear()
946
+ self.regex_check.setChecked(False)
947
+ self.tail_check.setChecked(False)
948
+
949
+ def on_log_received(self, log_record):
950
+ """Handler for when a new log record is received."""
951
+ # Add to model
952
+ self.log_model.add_log(log_record)
953
+
954
+ # Auto-scroll if enabled
955
+ if self.auto_scroll_check.isChecked():
956
+ self.log_table.scrollToBottom()
957
+
958
+ # Update status
959
+ self._update_status()
960
+
961
+ def _update_status(self):
962
+ """Update the status bar with current information."""
963
+ total = len(self.log_model.logs)
964
+ filtered = len(self.log_model.filtered_logs)
965
+
966
+ if total == filtered:
967
+ self.log_count_label.setText(f"{total} logs")
968
+ else:
969
+ self.log_count_label.setText(f"{filtered} / {total} logs")
970
+
971
+ def _clear_logs(self):
972
+ """Clear all logs from the display."""
973
+ reply = QMessageBox.question(
974
+ self, "Clear Logs",
975
+ "Are you sure you want to clear all logs?",
976
+ QMessageBox.Yes | QMessageBox.No, QMessageBox.No
977
+ )
978
+
979
+ if reply == QMessageBox.Yes:
980
+ self.log_model.clear()
981
+ self._update_status()
982
+
983
+ def _export_logs(self):
984
+ """Export logs to a CSV file."""
985
+ filename, _ = QFileDialog.getSaveFileName(
986
+ self, "Export Logs", "", "CSV Files (*.csv);;All Files (*)"
987
+ )
988
+
989
+ if filename:
990
+ try:
991
+ self.log_model.export_logs(filename)
992
+ self.status_bar.showMessage(f"Logs exported to {filename}", 5000)
993
+ except Exception as e:
994
+ QMessageBox.warning(self, "Export Error", f"Error exporting logs: {e}")
995
+
996
+ def _open_log_file(self):
997
+ """Open an existing log file."""
998
+ filename, _ = QFileDialog.getOpenFileName(
999
+ self, "Open Log File", "", "Log Files (*.log *.log*);;All Files (*)"
1000
+ )
1001
+
1002
+ if filename:
1003
+ try:
1004
+ # Clear current logs
1005
+ self.log_model.clear()
1006
+
1007
+ # Update the log monitor to watch this file
1008
+ self.log_monitor.set_log_file(filename)
1009
+
1010
+ # Parse and load the log file
1011
+ with open(filename, 'r') as f:
1012
+ for line in f:
1013
+ try:
1014
+ # Parse log line - assumes format: TIMESTAMP - LEVEL - NAME - MESSAGE
1015
+ parts = line.strip().split(' - ', 3)
1016
+ if len(parts) >= 4:
1017
+ timestamp, level, name, message = parts
1018
+ log_record = LogRecord(level, name, message, timestamp)
1019
+ self.log_model.add_log(log_record)
1020
+ except Exception as e:
1021
+ print(f"Error parsing log line: {e}")
1022
+
1023
+ self.status_bar.showMessage(f"Monitoring log file: {filename}", 5000)
1024
+ self._update_status()
1025
+ except Exception as e:
1026
+ QMessageBox.warning(self, "Open Error", f"Error opening log file: {e}")
1027
+
1028
+ def _show_context_menu(self, pos):
1029
+ """Show context menu for the log table."""
1030
+ menu = QMenu(self)
1031
+
1032
+ # Get selected rows
1033
+ selected_rows = [index.row() for index in self.log_table.selectionModel().selectedRows()]
1034
+
1035
+ if selected_rows:
1036
+ copy_action = menu.addAction("Copy Selected")
1037
+ copy_action.triggered.connect(self._copy_selected_logs)
1038
+
1039
+ menu.addSeparator()
1040
+
1041
+ menu.addAction(self.action_export)
1042
+
1043
+ menu.exec_(self.log_table.mapToGlobal(pos))
1044
+
1045
+ def _copy_selected_logs(self):
1046
+ """Copy selected log entries to clipboard."""
1047
+ selected_rows = sorted(set(index.row() for index in self.log_table.selectionModel().selectedRows()))
1048
+
1049
+ if not selected_rows:
1050
+ return
1051
+
1052
+ text = []
1053
+ for row in selected_rows:
1054
+ log_entry = self.log_model.filtered_logs[row]
1055
+ text.append(f"{log_entry['timestamp']} - {log_entry['level']} - {log_entry['source']} - {log_entry['message']}")
1056
+
1057
+ QApplication.clipboard().setText('\n'.join(text))
1058
+ self.status_bar.showMessage(f"Copied {len(text)} log entries to clipboard", 3000)
1059
+
1060
+ def _start_pipeline(self):
1061
+ """Start the main pipeline script."""
1062
+ # This is a placeholder method that could be used to start the pipeline
1063
+ # For now, just show a message
1064
+ QMessageBox.information(
1065
+ self,
1066
+ "Start Pipeline",
1067
+ "This feature is not implemented yet. In the future, it could be used to launch the pipeline directly from the GUI."
1068
+ )
1069
+
1070
+ def _refresh_monitor(self):
1071
+ """Refresh the log monitor by reloading the current log file."""
1072
+ if not self.log_monitor.log_file or not os.path.exists(self.log_monitor.log_file):
1073
+ self.status_bar.showMessage("No log file to refresh", 3000)
1074
+ return
1075
+
1076
+ # Remember the log file path
1077
+ log_file = self.log_monitor.log_file
1078
+
1079
+ # Temporarily disable auto-scroll during bulk load
1080
+ was_auto_scroll = self.auto_scroll_check.isChecked()
1081
+ self.auto_scroll_check.setChecked(False)
1082
+
1083
+ # Clear current logs
1084
+ self.log_model.clear()
1085
+
1086
+ # Reload the file
1087
+ try:
1088
+ with open(log_file, 'r') as f:
1089
+ for line in f:
1090
+ try:
1091
+ parts = line.strip().split(' - ', 3)
1092
+ if len(parts) >= 4:
1093
+ timestamp, level, name, message = parts
1094
+ log_record = LogRecord(level, name, message, timestamp)
1095
+ self.log_model.add_log(log_record)
1096
+ except Exception:
1097
+ pass
1098
+
1099
+ # Update file position for monitoring
1100
+ self.log_monitor.last_file_position = os.path.getsize(log_file)
1101
+
1102
+ self.status_bar.showMessage(f"Refreshed: {log_file}", 5000)
1103
+ except Exception as e:
1104
+ self.status_bar.showMessage(f"Error refreshing: {e}", 5000)
1105
+
1106
+ # Restore auto-scroll and scroll to bottom
1107
+ self.auto_scroll_check.setChecked(was_auto_scroll)
1108
+ if was_auto_scroll:
1109
+ self.log_table.scrollToBottom()
1110
+
1111
+ self._update_status()
1112
+
1113
+ def _check_file_size(self):
1114
+ """Check if the log file has changed and process new entries."""
1115
+ try:
1116
+ # Get the file size
1117
+ file_size = os.path.getsize(self.log_monitor.log_file)
1118
+
1119
+ # If file size has increased, read the new content
1120
+ if file_size > self.log_monitor.last_file_position:
1121
+ with open(self.log_monitor.log_file, 'r') as f:
1122
+ # Move to the last position we read
1123
+ f.seek(self.log_monitor.last_file_position)
1124
+
1125
+ # Read new lines
1126
+ new_lines = f.readlines()
1127
+
1128
+ # Update position
1129
+ self.log_monitor.last_file_position = file_size
1130
+
1131
+ # Process each new line
1132
+ for line in new_lines:
1133
+ try:
1134
+ # Parse log line - assumes format: TIMESTAMP - LEVEL - NAME - MESSAGE
1135
+ parts = line.strip().split(' - ', 3)
1136
+ if len(parts) >= 4:
1137
+ timestamp, level, name, message = parts
1138
+ log_record = LogRecord(level, name, message, timestamp)
1139
+ self.log_model.add_log(log_record)
1140
+ except Exception as e:
1141
+ print(f"Error parsing log line: {e}")
1142
+ except Exception as e:
1143
+ print(f"Error checking log file: {e}")
1144
+
1145
+ def _toggle_auto_refresh(self, state):
1146
+ """Toggle auto-refresh timer on/off."""
1147
+ if state == Qt.Checked:
1148
+ # Start timer to check file every 2 seconds
1149
+ self.refresh_timer.start(2000)
1150
+ self.status_bar.showMessage("Auto-refresh enabled", 2000)
1151
+ else:
1152
+ # Stop timer
1153
+ self.refresh_timer.stop()
1154
+ self.status_bar.showMessage("Auto-refresh disabled", 2000)
1155
+
1156
+ def closeEvent(self, event):
1157
+ """Handle window close event."""
1158
+ # Stop the log monitor thread
1159
+ if hasattr(self, 'log_monitor'):
1160
+ self.log_monitor.stop()
1161
+
1162
+ # Stop the refresh timer
1163
+ if hasattr(self, 'refresh_timer'):
1164
+ self.refresh_timer.stop()
1165
+
1166
+ # Accept the close event
1167
+ event.accept()
1168
+
1169
+
1170
+ def main():
1171
+ """Entry point for viewlogs command."""
1172
+ import argparse
1173
+ import os
1174
+
1175
+ parser = argparse.ArgumentParser(
1176
+ description="LOFAR Pipeline Log Viewer - View and analyze pipeline log files",
1177
+ usage="viewlogs [logfile] [--theme {dark,light}]"
1178
+ )
1179
+ parser.add_argument(
1180
+ "logfile", nargs="?", default=None,
1181
+ help="Log file to open (optional)"
1182
+ )
1183
+ parser.add_argument(
1184
+ "--theme", "-t", type=str, choices=["dark", "light"], default="dark",
1185
+ help="Color theme (dark or light, default: dark)"
1186
+ )
1187
+ args = parser.parse_args()
1188
+
1189
+ app = QApplication(sys.argv)
1190
+
1191
+ # Set application style
1192
+ app.setStyle("Fusion")
1193
+
1194
+ # Apply theme
1195
+ from solar_radio_image_viewer.from_simpl.simpl_theme import apply_theme
1196
+ apply_theme(app, args.theme)
1197
+
1198
+ # Create and show the main window with theme
1199
+ main_window = PipelineLoggerGUI(theme=args.theme)
1200
+ main_window.show()
1201
+
1202
+ # Open log file if specified
1203
+ if args.logfile:
1204
+ logfile_path = os.path.abspath(args.logfile)
1205
+ if os.path.exists(logfile_path):
1206
+ # Load the log file (same logic as _open_log_file)
1207
+ main_window.log_model.clear()
1208
+ main_window.log_monitor.set_log_file(logfile_path)
1209
+
1210
+ # Parse and load existing logs
1211
+ with open(logfile_path, 'r') as f:
1212
+ for line in f:
1213
+ try:
1214
+ parts = line.strip().split(' - ', 3)
1215
+ if len(parts) >= 4:
1216
+ timestamp, level, name, message = parts
1217
+ log_record = LogRecord(level, name, message, timestamp)
1218
+ main_window.log_model.add_log(log_record)
1219
+ except Exception:
1220
+ pass
1221
+
1222
+ main_window.status_bar.showMessage(f"Loaded: {logfile_path}", 5000)
1223
+ main_window._update_status()
1224
+ else:
1225
+ print(f"Warning: Log file not found: {logfile_path}")
1226
+
1227
+ # Start the application
1228
+ sys.exit(app.exec_())
1229
+
1230
+
1231
+ if __name__ == "__main__":
1232
+ main()