483 lines
17 KiB
Python
483 lines
17 KiB
Python
#!/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> | "
|
||
f"<b>Стратегия:</b> {strategy_ru}<br>"
|
||
f"<b>Модулей:</b> {len(report.modules)} | "
|
||
f"<b>Функций:</b> {report.total_functions} | "
|
||
f"<b>Ср. CCN:</b> {report.avg_system_ccn:.2f} | "
|
||
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()
|