arch-researcher/html_reporter.py

287 lines
12 KiB
Python
Raw Permalink Normal View History

2026-04-27 18:44:22 +00:00
"""
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>"""