287 lines
12 KiB
Python
287 lines
12 KiB
Python
"""
|
||
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> |
|
||
Сгенерировано: {ts} |
|
||
Системный риск: <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>"""
|