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.
- solar_radio_image_viewer/__init__.py +12 -0
- solar_radio_image_viewer/assets/add_tab_default.png +0 -0
- solar_radio_image_viewer/assets/add_tab_default_light.png +0 -0
- solar_radio_image_viewer/assets/add_tab_hover.png +0 -0
- solar_radio_image_viewer/assets/add_tab_hover_light.png +0 -0
- solar_radio_image_viewer/assets/browse.png +0 -0
- solar_radio_image_viewer/assets/browse_light.png +0 -0
- solar_radio_image_viewer/assets/close_tab_default.png +0 -0
- solar_radio_image_viewer/assets/close_tab_default_light.png +0 -0
- solar_radio_image_viewer/assets/close_tab_hover.png +0 -0
- solar_radio_image_viewer/assets/close_tab_hover_light.png +0 -0
- solar_radio_image_viewer/assets/ellipse_selection.png +0 -0
- solar_radio_image_viewer/assets/ellipse_selection_light.png +0 -0
- solar_radio_image_viewer/assets/icons8-ellipse-90.png +0 -0
- solar_radio_image_viewer/assets/icons8-ellipse-90_light.png +0 -0
- solar_radio_image_viewer/assets/icons8-info-90.png +0 -0
- solar_radio_image_viewer/assets/icons8-info-90_light.png +0 -0
- solar_radio_image_viewer/assets/profile.png +0 -0
- solar_radio_image_viewer/assets/profile_light.png +0 -0
- solar_radio_image_viewer/assets/rectangle_selection.png +0 -0
- solar_radio_image_viewer/assets/rectangle_selection_light.png +0 -0
- solar_radio_image_viewer/assets/reset.png +0 -0
- solar_radio_image_viewer/assets/reset_light.png +0 -0
- solar_radio_image_viewer/assets/ruler.png +0 -0
- solar_radio_image_viewer/assets/ruler_light.png +0 -0
- solar_radio_image_viewer/assets/search.png +0 -0
- solar_radio_image_viewer/assets/search_light.png +0 -0
- solar_radio_image_viewer/assets/settings.png +0 -0
- solar_radio_image_viewer/assets/settings_light.png +0 -0
- solar_radio_image_viewer/assets/splash.fits +0 -0
- solar_radio_image_viewer/assets/zoom_60arcmin.png +0 -0
- solar_radio_image_viewer/assets/zoom_60arcmin_light.png +0 -0
- solar_radio_image_viewer/assets/zoom_in.png +0 -0
- solar_radio_image_viewer/assets/zoom_in_light.png +0 -0
- solar_radio_image_viewer/assets/zoom_out.png +0 -0
- solar_radio_image_viewer/assets/zoom_out_light.png +0 -0
- solar_radio_image_viewer/create_video.py +1345 -0
- solar_radio_image_viewer/dialogs.py +2665 -0
- solar_radio_image_viewer/from_simpl/__init__.py +184 -0
- solar_radio_image_viewer/from_simpl/caltable_visualizer.py +1001 -0
- solar_radio_image_viewer/from_simpl/dynamic_spectra_dialog.py +332 -0
- solar_radio_image_viewer/from_simpl/make_dynamic_spectra.py +351 -0
- solar_radio_image_viewer/from_simpl/pipeline_logger_gui.py +1232 -0
- solar_radio_image_viewer/from_simpl/simpl_theme.py +352 -0
- solar_radio_image_viewer/from_simpl/utils.py +984 -0
- solar_radio_image_viewer/from_simpl/view_dynamic_spectra_GUI.py +1975 -0
- solar_radio_image_viewer/helioprojective.py +1916 -0
- solar_radio_image_viewer/helioprojective_viewer.py +817 -0
- solar_radio_image_viewer/helioviewer_browser.py +1514 -0
- solar_radio_image_viewer/main.py +148 -0
- solar_radio_image_viewer/move_phasecenter.py +1269 -0
- solar_radio_image_viewer/napari_viewer.py +368 -0
- solar_radio_image_viewer/noaa_events/__init__.py +32 -0
- solar_radio_image_viewer/noaa_events/noaa_events.py +430 -0
- solar_radio_image_viewer/noaa_events/noaa_events_gui.py +1922 -0
- solar_radio_image_viewer/norms.py +293 -0
- solar_radio_image_viewer/radio_data_downloader/__init__.py +25 -0
- solar_radio_image_viewer/radio_data_downloader/radio_data_downloader.py +756 -0
- solar_radio_image_viewer/radio_data_downloader/radio_data_downloader_gui.py +528 -0
- solar_radio_image_viewer/searchable_combobox.py +220 -0
- solar_radio_image_viewer/solar_context/__init__.py +41 -0
- solar_radio_image_viewer/solar_context/active_regions.py +371 -0
- solar_radio_image_viewer/solar_context/cme_alerts.py +234 -0
- solar_radio_image_viewer/solar_context/context_images.py +297 -0
- solar_radio_image_viewer/solar_context/realtime_data.py +528 -0
- solar_radio_image_viewer/solar_data_downloader/__init__.py +35 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader.py +1667 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_cli.py +901 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_gui.py +1210 -0
- solar_radio_image_viewer/styles.py +643 -0
- solar_radio_image_viewer/utils/__init__.py +32 -0
- solar_radio_image_viewer/utils/rate_limiter.py +255 -0
- solar_radio_image_viewer/utils.py +952 -0
- solar_radio_image_viewer/video_dialog.py +2629 -0
- solar_radio_image_viewer/video_utils.py +656 -0
- solar_radio_image_viewer/viewer.py +11174 -0
- solarviewer-1.0.2.dist-info/METADATA +343 -0
- solarviewer-1.0.2.dist-info/RECORD +82 -0
- solarviewer-1.0.2.dist-info/WHEEL +5 -0
- solarviewer-1.0.2.dist-info/entry_points.txt +8 -0
- solarviewer-1.0.2.dist-info/licenses/LICENSE +21 -0
- 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()
|