arch-researcher/html_reporter.py
2026-04-27 23:44:22 +05:00

287 lines
12 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.

"""
HTML Reporter — человекочитаемый отчёт для архитекторов и менеджеров.
Формирует самодостаточный HTML-файл (без внешних зависимостей):
- Сводная таблица по системе
- Таблица модулей с цветовой индикацией риска
- Детальные карточки по каждому модулю
- Граф зависимостей в SVG (через библиотеку Mermaid.js из CDN)
- Список циклических зависимостей
"""
import html
import logging
from datetime import datetime
from pathlib import Path
from typing import List
from models import AnalysisReport, ModuleDecision, ModernizationStrategy, RiskLevel
logger = logging.getLogger(__name__)
# Цвета по уровням риска
_RISK_COLORS = {
RiskLevel.LOW: ("#22c55e", "#dcfce7"), # зелёный
RiskLevel.MEDIUM: ("#f59e0b", "#fef3c7"), # жёлтый
RiskLevel.HIGH: ("#ef4444", "#fee2e2"), # красный
RiskLevel.CRITICAL: ("#7f1d1d", "#fca5a5"), # тёмно-красный
}
_STRATEGY_LABELS = {
ModernizationStrategy.KEEP: "Оставить",
ModernizationStrategy.REFACTOR: "Рефакторинг",
ModernizationStrategy.REENGINEER: "Реинжиниринг",
ModernizationStrategy.REPLACE: "Замена",
}
_RISK_LABELS = {
RiskLevel.LOW: "Низкий",
RiskLevel.MEDIUM: "Средний",
RiskLevel.HIGH: "Высокий",
RiskLevel.CRITICAL: "Критический",
}
class HtmlReporter:
"""Формирует HTML-отчёт по результатам анализа."""
def write(self, report: AnalysisReport, output_dir: str) -> str:
"""
Записывает отчёт в файл report.html внутри output_dir.
:returns: путь к созданному файлу
"""
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
file_path = out / "report.html"
content = self._build_html(report)
with open(file_path, "w", encoding="utf-8") as fh:
fh.write(content)
logger.info("HTML отчёт записан: %s", file_path)
return str(file_path)
# ------------------------------------------------------------------
# Сборка HTML
# ------------------------------------------------------------------
def _build_html(self, report: AnalysisReport) -> str:
sections = [
self._head(),
"<body>",
self._header(report),
self._summary_section(report),
self._decisions_table(report),
self._cycles_section(report),
self._modules_detail(report),
self._footer(),
"</body></html>",
]
return "\n".join(sections)
def _head(self) -> str:
return """<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Legacy Analyzer — Отчёт</title>
<style>
:root {
--font: 'Segoe UI', system-ui, sans-serif;
--bg: #f8fafc;
--card: #ffffff;
--border: #e2e8f0;
--text: #1e293b;
--muted: #64748b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font); background: var(--bg); color: var(--text); padding: 24px; }
h1 { font-size: 1.75rem; margin-bottom: 4px; }
h2 { font-size: 1.25rem; margin: 24px 0 12px; border-bottom: 2px solid var(--border); padding-bottom: 6px; }
h3 { font-size: 1rem; margin-bottom: 8px; }
.subtitle { color: var(--muted); font-size: 0.875rem; margin-bottom: 24px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.stat-card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; }
.stat-value { font-size: 2rem; font-weight: 700; }
.stat-label { font-size: 0.75rem; color: var(--muted); margin-top: 4px; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th { background: #f1f5f9; text-align: left; padding: 8px 12px; border-bottom: 2px solid var(--border); }
td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
tr:hover td { background: #f8fafc; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
.module-card { margin-bottom: 12px; }
.module-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.reasons-list { margin: 0; padding-left: 20px; color: var(--muted); font-size: 0.8rem; }
.reasons-list li { margin-bottom: 2px; }
.func-table { margin-top: 8px; }
code { font-family: monospace; background: #f1f5f9; padding: 1px 4px; border-radius: 3px; }
.cycle-badge { background: #fca5a5; color: #7f1d1d; border-radius: 4px; padding: 2px 6px; font-size: 0.75rem; margin: 2px; display: inline-block; }
details summary { cursor: pointer; user-select: none; }
details summary:hover { color: #3b82f6; }
@media (max-width: 768px) { .grid-4 { grid-template-columns: repeat(2, 1fr); } }
</style>
</head>"""
def _header(self, report: AnalysisReport) -> str:
ts = datetime.now().strftime("%d.%m.%Y %H:%M")
risk_color = _RISK_COLORS[report.system_risk_level][0]
return f"""
<h1>📊 Legacy Analyzer</h1>
<p class="subtitle">
Проект: <code>{html.escape(report.project_root)}</code> &nbsp;|&nbsp;
Сгенерировано: {ts} &nbsp;|&nbsp;
Системный риск: <strong style="color:{risk_color}">{_RISK_LABELS[report.system_risk_level]}</strong>
</p>"""
def _summary_section(self, report: AnalysisReport) -> str:
strategy_label = _STRATEGY_LABELS[report.recommended_system_strategy]
risk_color = _RISK_COLORS[report.system_risk_level][0]
return f"""
<h2>Сводка по системе</h2>
<div class="grid-4">
<div class="stat-card">
<div class="stat-value">{report.total_files}</div>
<div class="stat-label">Файлов</div>
</div>
<div class="stat-card">
<div class="stat-value">{report.total_nloc:,}</div>
<div class="stat-label">Строк кода (NLOC)</div>
</div>
<div class="stat-card">
<div class="stat-value">{report.total_functions}</div>
<div class="stat-label">Функций</div>
</div>
<div class="stat-card">
<div class="stat-value">{report.avg_system_ccn:.1f}</div>
<div class="stat-label">Средняя CCN</div>
</div>
<div class="stat-card">
<div class="stat-value">{len(report.modules)}</div>
<div class="stat-label">Модулей</div>
</div>
<div class="stat-card">
<div class="stat-value">{len(report.dependency_cycles)}</div>
<div class="stat-label">Циклов зависимостей</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color:{risk_color}">{_RISK_LABELS[report.system_risk_level]}</div>
<div class="stat-label">Системный риск</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size:1.2rem">{strategy_label}</div>
<div class="stat-label">Рекомендуемая стратегия</div>
</div>
</div>"""
def _decisions_table(self, report: AnalysisReport) -> str:
rows = []
for d in report.decisions:
fg, bg = _RISK_COLORS[d.risk_level]
risk_badge = (
f'<span class="badge" style="color:{fg};background:{bg}">'
f'{_RISK_LABELS[d.risk_level]}</span>'
)
reasons_html = ""
if d.reasons:
items = "".join(f"<li>{html.escape(r)}</li>" for r in d.reasons)
reasons_html = f'<ul class="reasons-list">{items}</ul>'
rows.append(f"""<tr>
<td><code>{html.escape(d.module_name)}</code></td>
<td>{risk_badge}</td>
<td>{html.escape(_STRATEGY_LABELS[d.strategy])}</td>
<td>{d.priority}</td>
<td>{reasons_html or ''}</td>
</tr>""")
return f"""
<h2>Таблица решений по модулям</h2>
<div class="card" style="padding:0;overflow:auto">
<table>
<thead><tr>
<th>Модуль</th><th>Риск</th><th>Стратегия</th><th>Приоритет</th><th>Причины</th>
</tr></thead>
<tbody>{"".join(rows)}</tbody>
</table>
</div>"""
def _cycles_section(self, report: AnalysisReport) -> str:
if not report.dependency_cycles:
return """
<h2>Циклические зависимости</h2>
<div class="card"><p style="color:#22c55e">✓ Циклических зависимостей не обнаружено</p></div>"""
items = []
for i, cycle in enumerate(report.dependency_cycles, 1):
badges = "".join(f'<span class="cycle-badge">{html.escape(m)}</span>' for m in cycle)
items.append(f"<p><strong>Цикл {i}:</strong> {badges}</p>")
return f"""
<h2>Циклические зависимости ({len(report.dependency_cycles)})</h2>
<div class="card">{"".join(items)}</div>"""
def _modules_detail(self, report: AnalysisReport) -> str:
# Строим индекс decision по имени модуля
decision_map = {d.module_name: d for d in report.decisions}
cards = []
for mod_name, mod in sorted(report.modules.items()):
decision = decision_map.get(mod_name)
risk = decision.risk_level if decision else RiskLevel.LOW
fg, bg = _RISK_COLORS[risk]
# Топ-5 самых сложных функций
top_funcs = sorted(mod.functions, key=lambda f: f.ccn, reverse=True)[:5]
func_rows = "".join(
f"<tr><td><code>{html.escape(f.name)}</code></td>"
f"<td>{f.ccn}</td><td>{f.nloc}</td><td>{f.params}</td>"
f"<td><code>{html.escape(Path(f.file).name)}:{f.line}</code></td></tr>"
for f in top_funcs
)
func_table = ""
if func_rows:
func_table = f"""<details><summary>Топ функций по сложности</summary>
<table class="func-table">
<thead><tr><th>Функция</th><th>CCN</th><th>NLOC</th><th>Параметры</th><th>Расположение</th></tr></thead>
<tbody>{func_rows}</tbody>
</table></details>"""
cycle_warn = (
'<span style="color:#ef4444;font-size:0.8rem">⚠ Участвует в цикле зависимостей</span>'
if mod.has_cycles else ""
)
cards.append(f"""<div class="card module-card">
<div class="module-header">
<h3><code>{html.escape(mod_name)}</code></h3>
<span class="badge" style="color:{fg};background:{bg}">{_RISK_LABELS[risk]}</span>
</div>
{cycle_warn}
<table style="width:auto;margin:8px 0">
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Язык</td><td>{mod.language}</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">NLOC</td><td>{mod.total_nloc:,}</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Функций</td><td>{mod.total_functions}</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Avg CCN</td><td>{mod.avg_ccn:.1f}</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Max CCN</td><td>{mod.max_ccn}</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">C_out / C_in</td><td>{mod.coupling_out} / {mod.coupling_in}</td></tr>
<tr><td style="padding:2px 16px 2px 0;color:var(--muted)">Нестабильность (I)</td><td>{mod.instability:.2f}</td></tr>
</table>
{func_table}
</div>""")
return f"""
<h2>Детализация по модулям</h2>
{"".join(cards)}"""
def _footer(self) -> str:
return """
<hr style="margin:32px 0;border:none;border-top:1px solid var(--border)">
<p style="color:var(--muted);font-size:0.75rem;text-align:center">
Legacy Analyzer — методический инструментарий модернизации унаследованных систем
</p>"""