Initial Codes

This commit is contained in:
2026-05-21 15:46:41 -04:00
commit b01ad5ea40
40 changed files with 9102 additions and 0 deletions
+384
View File
@@ -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)