vkr/prog/arch-researcher/gui.py
2026-05-21 08:09:36 +05:00

483 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
PyQt5 GUI для Legacy Analyzer.
Запуск:
python gui.py
pip install PyQt5 # если не установлен
"""
import logging
import os
import sys
from pathlib import Path
from PyQt5.QtCore import QThread, pyqtSignal, Qt
from PyQt5.QtGui import QColor, QFont, QTextCursor
from PyQt5.QtWidgets import (
QApplication, QCheckBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow,
QPushButton, QSizePolicy, QSpinBox, QSplitter, QTableWidget,
QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget,
)
# Добавляем директорию скрипта в sys.path, чтобы импорты работали при запуске
# gui.py из любой рабочей директории
_HERE = Path(__file__).parent
if str(_HERE) not in sys.path:
sys.path.insert(0, str(_HERE))
from config import AnalyzerConfig, ThresholdConfig
from analyzer import Analyzer
from models import RiskLevel, ModernizationStrategy
# ---------------------------------------------------------------------------
# Цвета уровней риска
# ---------------------------------------------------------------------------
RISK_COLORS = {
RiskLevel.LOW: "#27ae60",
RiskLevel.MEDIUM: "#f39c12",
RiskLevel.HIGH: "#e67e22",
RiskLevel.CRITICAL: "#e74c3c",
}
RISK_BG = {
RiskLevel.LOW: "#eafaf1",
RiskLevel.MEDIUM: "#fef9e7",
RiskLevel.HIGH: "#fef0e6",
RiskLevel.CRITICAL: "#fdedec",
}
STRATEGY_RU = {
ModernizationStrategy.KEEP: "Оставить",
ModernizationStrategy.REFACTOR: "Рефакторинг",
ModernizationStrategy.REENGINEER: "Реинжиниринг",
ModernizationStrategy.REPLACE: "Замена",
}
RISK_RU = {
RiskLevel.LOW: "НИЗКИЙ",
RiskLevel.MEDIUM: "СРЕДНИЙ",
RiskLevel.HIGH: "ВЫСОКИЙ",
RiskLevel.CRITICAL: "КРИТИЧЕСКИЙ",
}
LOG_LEVEL_COLORS = {
"DEBUG": "#888888",
"INFO": "#ecf0f1",
"WARNING": "#f39c12",
"ERROR": "#e74c3c",
"CRITICAL":"#e74c3c",
}
# ---------------------------------------------------------------------------
# Фоновый поток анализа
# ---------------------------------------------------------------------------
class _QtLogHandler(logging.Handler):
"""Перехватывает logging и отправляет сообщения в Qt-сигнал."""
def __init__(self, signal):
super().__init__()
self._signal = signal
def emit(self, record):
try:
self._signal.emit(self.format(record), record.levelname)
except Exception:
pass
class AnalysisWorker(QThread):
log_signal = pyqtSignal(str, str) # (message, levelname)
finished_signal = pyqtSignal(object) # AnalysisReport
error_signal = pyqtSignal(str)
def __init__(self, project_root: str, config: AnalyzerConfig):
super().__init__()
self._project_root = project_root
self._config = config
def run(self):
handler = _QtLogHandler(self.log_signal)
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S"))
root_logger = logging.getLogger()
root_logger.addHandler(handler)
root_logger.setLevel(logging.DEBUG)
try:
analyzer = Analyzer(self._config)
report = analyzer.run(self._project_root)
self.finished_signal.emit(report)
except Exception as exc:
self.error_signal.emit(str(exc))
finally:
root_logger.removeHandler(handler)
# ---------------------------------------------------------------------------
# Панель настроек (левая)
# ---------------------------------------------------------------------------
class _SettingsPanel(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(10)
# --- Проект ---
proj_box = QGroupBox("Проект")
proj_form = QFormLayout(proj_box)
proj_form.setLabelAlignment(Qt.AlignRight)
self.project_edit = QLineEdit()
self.project_edit.setPlaceholderText("Путь к анализируемому проекту")
proj_btn = QPushButton("")
proj_btn.setFixedWidth(30)
proj_btn.clicked.connect(self._browse_project)
proj_row = QHBoxLayout()
proj_row.addWidget(self.project_edit)
proj_row.addWidget(proj_btn)
self.output_edit = QLineEdit("legacy_report")
self.output_edit.setPlaceholderText("Директория для отчётов")
out_btn = QPushButton("")
out_btn.setFixedWidth(30)
out_btn.clicked.connect(self._browse_output)
out_row = QHBoxLayout()
out_row.addWidget(self.output_edit)
out_row.addWidget(out_btn)
self.libclang_edit = QLineEdit()
self.libclang_edit.setPlaceholderText("Авто (опционально)")
lc_btn = QPushButton("")
lc_btn.setFixedWidth(30)
lc_btn.clicked.connect(self._browse_libclang)
lc_row = QHBoxLayout()
lc_row.addWidget(self.libclang_edit)
lc_row.addWidget(lc_btn)
proj_form.addRow("Проект:", proj_row)
proj_form.addRow("Вывод:", out_row)
proj_form.addRow("libclang:", lc_row)
# --- Форматы ---
fmt_box = QGroupBox("Форматы отчётов")
fmt_layout = QHBoxLayout(fmt_box)
self.fmt_json = QCheckBox("JSON")
self.fmt_json.setChecked(True)
self.fmt_html = QCheckBox("HTML")
self.fmt_html.setChecked(True)
fmt_layout.addWidget(self.fmt_json)
fmt_layout.addWidget(self.fmt_html)
fmt_layout.addStretch()
# --- Пороги ---
thr_box = QGroupBox("Пороговые значения")
thr_form = QFormLayout(thr_box)
thr_form.setLabelAlignment(Qt.AlignRight)
self.ccn_warn = self._spinbox(1, 100, 10)
self.ccn_crit = self._spinbox(1, 100, 20)
self.coup_warn = self._spinbox(1, 100, 5)
self.coup_crit = self._spinbox(1, 100, 10)
thr_form.addRow("CCN предупрежд.:", self.ccn_warn)
thr_form.addRow("CCN критическое:", self.ccn_crit)
thr_form.addRow("Связность вых. (пред.):", self.coup_warn)
thr_form.addRow("Связность вых. (крит.):", self.coup_crit)
# --- Кнопка запуска ---
self.run_btn = QPushButton("▶ Запустить анализ")
self.run_btn.setMinimumHeight(40)
self.run_btn.setStyleSheet(
"QPushButton { background: #2980b9; color: white; font-weight: bold; "
"border-radius: 4px; font-size: 13px; }"
"QPushButton:hover { background: #3498db; }"
"QPushButton:disabled { background: #7f8c8d; }"
)
layout.addWidget(proj_box)
layout.addWidget(fmt_box)
layout.addWidget(thr_box)
layout.addStretch()
layout.addWidget(self.run_btn)
@staticmethod
def _spinbox(minimum, maximum, default) -> QSpinBox:
sb = QSpinBox()
sb.setRange(minimum, maximum)
sb.setValue(default)
return sb
def _browse_project(self):
d = QFileDialog.getExistingDirectory(self, "Выберите директорию проекта")
if d:
self.project_edit.setText(d)
def _browse_output(self):
d = QFileDialog.getExistingDirectory(self, "Директория для отчётов")
if d:
self.output_edit.setText(d)
def _browse_libclang(self):
f, _ = QFileDialog.getOpenFileName(self, "Путь к libclang", "",
"Библиотеки (*.so *.dylib *.dll);;Все файлы (*)")
if f:
self.libclang_edit.setText(f)
def build_config(self) -> AnalyzerConfig:
formats = []
if self.fmt_json.isChecked():
formats.append("json")
if self.fmt_html.isChecked():
formats.append("html")
if not formats:
formats = ["json"]
thresholds = ThresholdConfig(
ccn_warn=self.ccn_warn.value(),
ccn_critical=self.ccn_crit.value(),
coupling_out_warn=self.coup_warn.value(),
coupling_out_critical=self.coup_crit.value(),
)
libclang = self.libclang_edit.text().strip() or None
return AnalyzerConfig(
output_formats=formats,
output_dir=self.output_edit.text().strip() or "legacy_report",
libclang_path=libclang,
thresholds=thresholds,
)
# ---------------------------------------------------------------------------
# Панель лога (правая верхняя)
# ---------------------------------------------------------------------------
class _LogPanel(QTextEdit):
def __init__(self, parent=None):
super().__init__(parent)
self.setReadOnly(True)
self.setFont(QFont("Monospace", 9))
self.setStyleSheet("background: #1e1e1e; color: #ecf0f1;")
def append_log(self, message: str, level: str):
color = LOG_LEVEL_COLORS.get(level, "#ecf0f1")
html = f'<span style="color:{color};">{message}</span>'
self.append(html)
self.moveCursor(QTextCursor.End)
def clear_log(self):
self.clear()
# ---------------------------------------------------------------------------
# Панель результатов (правая нижняя)
# ---------------------------------------------------------------------------
class _ResultsPanel(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._html_path: str = ""
self._build_ui()
self.setVisible(False)
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(8, 4, 8, 8)
layout.setSpacing(6)
# Заголовок
title = QLabel("Результаты анализа")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
layout.addWidget(title)
# Сводка
self._summary = QLabel()
self._summary.setWordWrap(True)
self._summary.setStyleSheet("font-size: 12px;")
layout.addWidget(self._summary)
# Таблица решений
self._table = QTableWidget()
self._table.setColumnCount(4)
self._table.setHorizontalHeaderLabels(["Модуль", "Риск", "Стратегия", "Причины"])
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self._table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch)
self._table.setEditTriggers(QTableWidget.NoEditTriggers)
self._table.setSelectionBehavior(QTableWidget.SelectRows)
self._table.setAlternatingRowColors(True)
layout.addWidget(self._table)
# Кнопка открытия отчёта
self._open_btn = QPushButton("Открыть HTML отчёт")
self._open_btn.setStyleSheet(
"QPushButton { background: #27ae60; color: white; border-radius: 4px; padding: 6px; }"
"QPushButton:hover { background: #2ecc71; }"
"QPushButton:disabled { background: #7f8c8d; }"
)
self._open_btn.clicked.connect(self._open_html)
self._open_btn.setEnabled(False)
layout.addWidget(self._open_btn)
def show_report(self, report, output_dir: str):
from models import RiskLevel
risk = report.system_risk_level
strategy = report.recommended_system_strategy
color = RISK_COLORS.get(risk, "#ecf0f1")
risk_ru = RISK_RU.get(risk, risk.name)
strategy_ru = STRATEGY_RU.get(strategy, strategy.name)
summary_html = (
f"<b>Системный риск:</b> <span style='color:{color};font-weight:bold;'>{risk_ru}</span> &nbsp;|&nbsp; "
f"<b>Стратегия:</b> {strategy_ru}<br>"
f"<b>Модулей:</b> {len(report.modules)} &nbsp;|&nbsp; "
f"<b>Функций:</b> {report.total_functions} &nbsp;|&nbsp; "
f"<b>Ср. CCN:</b> {report.avg_system_ccn:.2f} &nbsp;|&nbsp; "
f"<b>Циклов зависим.:</b> {len(report.dependency_cycles)}"
)
self._summary.setText(summary_html)
# Заполняем таблицу
self._table.setRowCount(len(report.decisions))
for row, decision in enumerate(report.decisions):
r = decision.risk_level
bg = QColor(RISK_BG.get(r, "#ffffff"))
fg = QColor(RISK_COLORS.get(r, "#000000"))
items = [
decision.module_name,
RISK_RU.get(r, r.name),
STRATEGY_RU.get(decision.strategy, decision.strategy.name),
"; ".join(decision.reasons) if decision.reasons else "",
]
for col, text in enumerate(items):
item = QTableWidgetItem(text)
item.setBackground(bg)
if col == 1:
item.setForeground(fg)
item.setFont(QFont("", -1, QFont.Bold))
self._table.setItem(row, col, item)
self._table.resizeRowsToContents()
# HTML отчёт
html_path = Path(output_dir) / "report.html"
if not html_path.is_absolute():
html_path = Path.cwd() / html_path
if html_path.exists():
self._html_path = str(html_path)
self._open_btn.setEnabled(True)
else:
self._html_path = ""
self._open_btn.setEnabled(False)
self.setVisible(True)
def _open_html(self):
if self._html_path:
import webbrowser
webbrowser.open(f"file://{self._html_path}")
# ---------------------------------------------------------------------------
# Главное окно
# ---------------------------------------------------------------------------
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Legacy Analyzer")
self.resize(1100, 700)
self._worker: AnalysisWorker | None = None
self._build_ui()
def _build_ui(self):
central = QWidget()
self.setCentralWidget(central)
root_layout = QHBoxLayout(central)
root_layout.setContentsMargins(6, 6, 6, 6)
root_layout.setSpacing(6)
# Левая панель — настройки
self._settings = _SettingsPanel()
self._settings.setFixedWidth(320)
self._settings.run_btn.clicked.connect(self._run_analysis)
# Правая панель — лог + результаты
right_splitter = QSplitter(Qt.Vertical)
self._log = _LogPanel()
self._log.setMinimumHeight(150)
self._results = _ResultsPanel()
self._results.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
right_splitter.addWidget(self._log)
right_splitter.addWidget(self._results)
right_splitter.setSizes([300, 300])
root_layout.addWidget(self._settings)
root_layout.addWidget(right_splitter, stretch=1)
def _run_analysis(self):
project_root = self._settings.project_edit.text().strip()
if not project_root:
self._log.append_log("Укажите путь к проекту.", "ERROR")
return
if not Path(project_root).exists():
self._log.append_log(f"Директория не найдена: {project_root}", "ERROR")
return
config = self._settings.build_config()
self._log.clear_log()
self._results.setVisible(False)
self._settings.run_btn.setEnabled(False)
self._settings.run_btn.setText("⏳ Анализ...")
self._worker = AnalysisWorker(project_root, config)
self._worker.log_signal.connect(self._log.append_log)
self._worker.finished_signal.connect(self._on_finished)
self._worker.error_signal.connect(self._on_error)
self._worker.start()
def _on_finished(self, report):
self._reset_run_btn()
output_dir = self._settings.output_edit.text().strip() or "legacy_report"
self._results.show_report(report, output_dir)
self._log.append_log("Анализ завершён.", "INFO")
def _on_error(self, message: str):
self._reset_run_btn()
self._log.append_log(f"Ошибка: {message}", "ERROR")
def _reset_run_btn(self):
self._settings.run_btn.setEnabled(True)
self._settings.run_btn.setText("▶ Запустить анализ")
# ---------------------------------------------------------------------------
# Точка входа
# ---------------------------------------------------------------------------
def main():
app = QApplication(sys.argv)
app.setStyle("Fusion")
window = MainWindow()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()