Initial Codes
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
In-app log viewer.
|
||||
|
||||
Opens as a non-modal window (or a workspace tab) and shows the contents
|
||||
of ~/.dbclient/logs/dbclient.log with:
|
||||
• Level filter buttons (ALL / DEBUG / INFO / WARNING / ERROR / CRITICAL)
|
||||
• Auto-tail mode (follows the file like `tail -f`)
|
||||
• Colour coding per level
|
||||
• Search / highlight
|
||||
• "Open folder" button — opens the logs directory in Explorer/Finder
|
||||
• "Copy" and "Clear view" (does NOT delete the log file)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QPlainTextEdit,
|
||||
QLineEdit, QCheckBox, QButtonGroup, QAbstractButton,
|
||||
QFileDialog, QApplication, QSizePolicy,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import (
|
||||
QColor, QTextCharFormat, QFont,
|
||||
QSyntaxHighlighter, QTextDocument,
|
||||
QTextCursor,
|
||||
)
|
||||
|
||||
from app.utils.logger import get_log_path
|
||||
|
||||
# ── Level colours (Catppuccin Mocha palette) ──────────────────────────────────
|
||||
|
||||
_LEVEL_COLOURS: dict[str, str] = {
|
||||
"DEBUG": "#6c7086", # surface2 / dimmed
|
||||
"INFO": "#cdd6f4", # text (default)
|
||||
"WARNING": "#f9e2af", # yellow
|
||||
"ERROR": "#f38ba8", # red
|
||||
"CRITICAL": "#ff79c6", # pink / bright red
|
||||
}
|
||||
|
||||
_LEVEL_ORDER = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||
_LEVEL_RANK = {lv: i for i, lv in enumerate(_LEVEL_ORDER)}
|
||||
|
||||
|
||||
# ── Syntax highlighter ────────────────────────────────────────────────────────
|
||||
|
||||
class _LogHighlighter(QSyntaxHighlighter):
|
||||
"""Colour-code log lines by severity level."""
|
||||
|
||||
def __init__(self, document: QTextDocument, min_level: str = "DEBUG"):
|
||||
super().__init__(document)
|
||||
self._min_rank = _LEVEL_RANK.get(min_level, 0)
|
||||
self._search: str = ""
|
||||
|
||||
def set_min_level(self, level: str):
|
||||
self._min_rank = _LEVEL_RANK.get(level, 0)
|
||||
self.rehighlight()
|
||||
|
||||
def set_search(self, text: str):
|
||||
self._search = text.lower()
|
||||
self.rehighlight()
|
||||
|
||||
def highlightBlock(self, text: str):
|
||||
# Detect level from the bracketed token e.g. [WARNING ]
|
||||
level_colour = _LEVEL_COLOURS["INFO"]
|
||||
matched_level = "INFO"
|
||||
for level in _LEVEL_ORDER:
|
||||
if f"[{level}" in text:
|
||||
matched_level = level
|
||||
level_colour = _LEVEL_COLOURS[level]
|
||||
break
|
||||
|
||||
# Apply base colour for the whole line
|
||||
fmt = QTextCharFormat()
|
||||
fmt.setForeground(QColor(level_colour))
|
||||
if matched_level in ("ERROR", "CRITICAL"):
|
||||
# Bold for high-severity lines
|
||||
f = QFont()
|
||||
f.setBold(True)
|
||||
fmt.setFont(f)
|
||||
self.setFormat(0, len(text), fmt)
|
||||
|
||||
# Highlight search matches in bright yellow
|
||||
if self._search:
|
||||
search_fmt = QTextCharFormat()
|
||||
search_fmt.setBackground(QColor("#f9e2af"))
|
||||
search_fmt.setForeground(QColor("#1e1e2e"))
|
||||
idx = text.lower().find(self._search)
|
||||
while idx != -1:
|
||||
self.setFormat(idx, len(self._search), search_fmt)
|
||||
idx = text.lower().find(self._search, idx + 1)
|
||||
|
||||
|
||||
# ── Level filter button ───────────────────────────────────────────────────────
|
||||
|
||||
class _LevelBtn(QPushButton):
|
||||
def __init__(self, level: str, colour: str, parent=None):
|
||||
label = "ALL" if level == "DEBUG" else level
|
||||
super().__init__(label, parent)
|
||||
self.setCheckable(True)
|
||||
self.setFixedHeight(26)
|
||||
self._colour = colour
|
||||
self._apply_style(False)
|
||||
|
||||
def _apply_style(self, checked: bool):
|
||||
if checked:
|
||||
self.setStyleSheet(
|
||||
f"QPushButton {{ background: {self._colour}; color: #1e1e2e; "
|
||||
f"border: none; border-radius: 3px; font-weight: bold; }}"
|
||||
)
|
||||
else:
|
||||
self.setStyleSheet(
|
||||
f"QPushButton {{ background: transparent; color: {self._colour}; "
|
||||
f"border: 1px solid {self._colour}; border-radius: 3px; }}"
|
||||
f"QPushButton:hover {{ background: {self._colour}22; }}"
|
||||
)
|
||||
|
||||
# Override to auto-apply style on toggle
|
||||
def setChecked(self, v: bool):
|
||||
super().setChecked(v)
|
||||
self._apply_style(v)
|
||||
|
||||
|
||||
# ── Main Log Viewer widget ────────────────────────────────────────────────────
|
||||
|
||||
class LogViewer(QWidget):
|
||||
"""
|
||||
Non-modal log viewer window.
|
||||
|
||||
Can be used stand-alone (as a top-level window) or embedded
|
||||
as a workspace tab — it's just a QWidget.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._log_path = get_log_path()
|
||||
self._file_pos = 0 # byte offset for tail mode
|
||||
self._min_level = "DEBUG" # currently selected filter
|
||||
self._auto_tail = True
|
||||
self._build_ui()
|
||||
self._load_full()
|
||||
|
||||
# Auto-tail timer (500 ms poll)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(500)
|
||||
self._timer.timeout.connect(self._tail)
|
||||
self._timer.start()
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(6, 6, 6, 6)
|
||||
root.setSpacing(6)
|
||||
|
||||
# ── Top bar ───────────────────────────────────────────────────────────
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(6)
|
||||
|
||||
title = QLabel("📋 Application Log")
|
||||
f = title.font()
|
||||
f.setBold(True)
|
||||
f.setPointSize(11)
|
||||
title.setFont(f)
|
||||
title.setObjectName("structureTitle")
|
||||
top.addWidget(title)
|
||||
top.addStretch()
|
||||
|
||||
self._path_lbl = QLabel(str(self._log_path))
|
||||
self._path_lbl.setObjectName("rowCountLbl")
|
||||
self._path_lbl.setWordWrap(False)
|
||||
top.addWidget(self._path_lbl)
|
||||
|
||||
open_dir_btn = QPushButton("📁 Open Folder")
|
||||
open_dir_btn.setFixedHeight(26)
|
||||
open_dir_btn.clicked.connect(self._open_log_folder)
|
||||
top.addWidget(open_dir_btn)
|
||||
|
||||
root.addLayout(top)
|
||||
|
||||
# ── Toolbar row ───────────────────────────────────────────────────────
|
||||
tb = QHBoxLayout()
|
||||
tb.setSpacing(6)
|
||||
|
||||
# Level filter buttons
|
||||
tb.addWidget(QLabel("Filter:"))
|
||||
self._level_btns: dict[str, _LevelBtn] = {}
|
||||
self._btn_group = QButtonGroup(self)
|
||||
self._btn_group.setExclusive(True)
|
||||
|
||||
for level in _LEVEL_ORDER:
|
||||
colour = _LEVEL_COLOURS[level]
|
||||
btn = _LevelBtn(level, colour, self)
|
||||
btn.setFixedWidth(76 if level == "DEBUG" else 82)
|
||||
self._btn_group.addButton(btn)
|
||||
self._level_btns[level] = btn
|
||||
tb.addWidget(btn)
|
||||
btn.clicked.connect(lambda checked, lv=level: self._set_level(lv))
|
||||
|
||||
self._level_btns["DEBUG"].setChecked(True) # "ALL" starts checked
|
||||
|
||||
tb.addSpacing(10)
|
||||
|
||||
# Search
|
||||
tb.addWidget(QLabel("Search:"))
|
||||
self._search_box = QLineEdit()
|
||||
self._search_box.setPlaceholderText("Highlight text…")
|
||||
self._search_box.setFixedWidth(180)
|
||||
self._search_box.setFixedHeight(26)
|
||||
self._search_box.textChanged.connect(self._on_search)
|
||||
tb.addWidget(self._search_box)
|
||||
|
||||
tb.addStretch()
|
||||
|
||||
# Auto-tail toggle
|
||||
self._tail_cb = QCheckBox("Auto-scroll")
|
||||
self._tail_cb.setChecked(True)
|
||||
self._tail_cb.toggled.connect(self._on_tail_toggle)
|
||||
tb.addWidget(self._tail_cb)
|
||||
|
||||
# Action buttons
|
||||
copy_btn = QPushButton("📋 Copy All")
|
||||
copy_btn.setFixedHeight(26)
|
||||
copy_btn.clicked.connect(self._copy_all)
|
||||
tb.addWidget(copy_btn)
|
||||
|
||||
clear_btn = QPushButton("🗑 Clear View")
|
||||
clear_btn.setFixedHeight(26)
|
||||
clear_btn.setToolTip("Clears the viewer only — does not delete the log file")
|
||||
clear_btn.clicked.connect(self._clear_view)
|
||||
tb.addWidget(clear_btn)
|
||||
|
||||
refresh_btn = QPushButton("🔄 Refresh")
|
||||
refresh_btn.setFixedHeight(26)
|
||||
refresh_btn.clicked.connect(self._load_full)
|
||||
tb.addWidget(refresh_btn)
|
||||
|
||||
root.addLayout(tb)
|
||||
|
||||
# ── Log text area ─────────────────────────────────────────────────────
|
||||
self._text = QPlainTextEdit()
|
||||
self._text.setReadOnly(True)
|
||||
mono = QFont("Consolas", 10)
|
||||
mono.setFixedPitch(True)
|
||||
self._text.setFont(mono)
|
||||
self._text.setMaximumBlockCount(20_000) # cap at 20k lines in view
|
||||
root.addWidget(self._text, 1)
|
||||
|
||||
self._highlighter = _LogHighlighter(self._text.document())
|
||||
|
||||
# ── Status bar ────────────────────────────────────────────────────────
|
||||
self._status_lbl = QLabel("Ready")
|
||||
self._status_lbl.setObjectName("rowCountLbl")
|
||||
root.addWidget(self._status_lbl)
|
||||
|
||||
# ── Data loading ──────────────────────────────────────────────────────────
|
||||
|
||||
def _load_full(self):
|
||||
"""Read the entire log file and populate the viewer."""
|
||||
self._text.clear()
|
||||
if not self._log_path.exists():
|
||||
self._text.setPlainText("No log file found yet.\n"
|
||||
f"Expected location: {self._log_path}")
|
||||
self._file_pos = 0
|
||||
return
|
||||
try:
|
||||
with open(self._log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
self._file_pos = f.tell()
|
||||
|
||||
lines = self._filter_lines(content.splitlines())
|
||||
self._text.setPlainText("\n".join(lines))
|
||||
self._status_lbl.setText(
|
||||
f"{len(lines)} lines | {self._log_path}"
|
||||
)
|
||||
if self._auto_tail:
|
||||
self._scroll_to_bottom()
|
||||
except OSError as e:
|
||||
self._text.setPlainText(f"Cannot read log file:\n{e}")
|
||||
|
||||
def _tail(self):
|
||||
"""Append any new lines written since last poll."""
|
||||
if not self._log_path.exists():
|
||||
return
|
||||
try:
|
||||
size = self._log_path.stat().st_size
|
||||
if size < self._file_pos:
|
||||
# File was rotated / truncated — reload from scratch
|
||||
self._load_full()
|
||||
return
|
||||
if size == self._file_pos:
|
||||
return
|
||||
|
||||
with open(self._log_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
f.seek(self._file_pos)
|
||||
new_text = f.read()
|
||||
self._file_pos = f.tell()
|
||||
|
||||
if not new_text:
|
||||
return
|
||||
|
||||
lines = self._filter_lines(new_text.splitlines())
|
||||
if not lines:
|
||||
return
|
||||
|
||||
cursor = self._text.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
cursor.insertText(("\n" if self._text.toPlainText() else "") +
|
||||
"\n".join(lines))
|
||||
self._text.setTextCursor(cursor)
|
||||
|
||||
if self._auto_tail:
|
||||
self._scroll_to_bottom()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _filter_lines(self, lines: list[str]) -> list[str]:
|
||||
"""Keep only lines at or above self._min_level, plus continuation lines."""
|
||||
if self._min_level == "DEBUG":
|
||||
return lines
|
||||
min_rank = _LEVEL_RANK[self._min_level]
|
||||
kept = []
|
||||
include_next = False
|
||||
for line in lines:
|
||||
# Detect the level tag in the [LEVEL ] bracket
|
||||
matched = False
|
||||
for level in _LEVEL_ORDER:
|
||||
if f"[{level}" in line:
|
||||
include_next = (_LEVEL_RANK[level] >= min_rank)
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
# Continuation line (traceback etc.) — follow parent's decision
|
||||
pass
|
||||
if include_next:
|
||||
kept.append(line)
|
||||
return kept
|
||||
|
||||
# ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _set_level(self, level: str):
|
||||
self._min_level = level
|
||||
self._highlighter.set_min_level(level)
|
||||
self._load_full()
|
||||
|
||||
def _on_search(self, text: str):
|
||||
self._highlighter.set_search(text)
|
||||
|
||||
def _on_tail_toggle(self, checked: bool):
|
||||
self._auto_tail = checked
|
||||
if checked:
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def _scroll_to_bottom(self):
|
||||
sb = self._text.verticalScrollBar()
|
||||
sb.setValue(sb.maximum())
|
||||
|
||||
def _copy_all(self):
|
||||
QApplication.clipboard().setText(self._text.toPlainText())
|
||||
self._status_lbl.setText("Copied to clipboard.")
|
||||
|
||||
def _clear_view(self):
|
||||
self._text.clear()
|
||||
self._status_lbl.setText("View cleared (log file is untouched).")
|
||||
|
||||
def _open_log_folder(self):
|
||||
folder = str(self._log_path.parent)
|
||||
try:
|
||||
import subprocess, platform
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
subprocess.Popen(["explorer", folder])
|
||||
elif system == "Darwin":
|
||||
subprocess.Popen(["open", folder])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", folder])
|
||||
except Exception as e:
|
||||
self._status_lbl.setText(f"Could not open folder: {e}")
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._timer.stop()
|
||||
super().closeEvent(event)
|
||||
Reference in New Issue
Block a user