Split HTML5 inspector renderer
This commit is contained in:
@@ -1,34 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from html import escape
|
||||
from typing import Iterable
|
||||
from urllib.parse import quote
|
||||
|
||||
from sir import NodeKind
|
||||
|
||||
_HTML5_OBJECT_CONTEXT_KINDS = {
|
||||
NodeKind.CATALOG.value,
|
||||
NodeKind.DOCUMENT.value,
|
||||
NodeKind.REGISTER.value,
|
||||
NodeKind.COMMON_MODULE.value,
|
||||
NodeKind.CONSTANT.value,
|
||||
NodeKind.DOCUMENT_JOURNAL.value,
|
||||
NodeKind.ENUM.value,
|
||||
NodeKind.REPORT.value,
|
||||
NodeKind.DATA_PROCESSOR.value,
|
||||
NodeKind.CHART_OF_CHARACTERISTIC_TYPES.value,
|
||||
NodeKind.CHART_OF_ACCOUNTS.value,
|
||||
NodeKind.CHART_OF_CALCULATION_TYPES.value,
|
||||
NodeKind.EXCHANGE_PLAN.value,
|
||||
NodeKind.EXTERNAL_DATA_SOURCE.value,
|
||||
NodeKind.SCHEDULED_JOB.value,
|
||||
NodeKind.BUSINESS_PROCESS.value,
|
||||
NodeKind.TASK.value,
|
||||
}
|
||||
if hasattr(NodeKind, "EVENT_SUBSCRIPTION"):
|
||||
_HTML5_OBJECT_CONTEXT_KINDS.add(NodeKind.EVENT_SUBSCRIPTION.value)
|
||||
|
||||
|
||||
def render_html5_index(projects: Iterable[object]) -> str:
|
||||
project_list = list(projects)
|
||||
@@ -95,384 +70,6 @@ def render_html5_project_rows(projects: Iterable[object]) -> str:
|
||||
return project_rows
|
||||
|
||||
|
||||
def render_html5_flowchart(
|
||||
project_id: str,
|
||||
flowchart: object | None,
|
||||
*,
|
||||
focus: str | None = None,
|
||||
depth: int = 1,
|
||||
oob: bool = False,
|
||||
) -> str:
|
||||
normalized_depth = min(max(depth, 1), 3)
|
||||
hx_url = _flowchart_url(project_id, focus, normalized_depth)
|
||||
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
|
||||
live_attr = ' sse-swap="project-flowchart"' if focus is None else ""
|
||||
if flowchart is None:
|
||||
return f"""
|
||||
<div
|
||||
class="flowchart-panel"
|
||||
data-html5-flowchart
|
||||
hx-get="{hx_url}"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
{oob_attr}
|
||||
{live_attr}
|
||||
>
|
||||
<div class="panel-title">Карта связей</div>
|
||||
<p class="muted padded">Сервер собирает граф проекта.</p>
|
||||
</div>
|
||||
"""
|
||||
nodes = getattr(flowchart, "nodes", []) or []
|
||||
edges = getattr(flowchart, "edges", []) or []
|
||||
mode = str(getattr(flowchart, "mode", "overview"))
|
||||
body = "".join(_flowchart_edge_item(project_id, item, nodes, normalized_depth) for item in edges[:10])
|
||||
if not body:
|
||||
body = "".join(_flowchart_node_item(project_id, item, normalized_depth) for item in nodes[:10])
|
||||
if not body:
|
||||
body = '<p class="muted padded">Связи проекта не найдены</p>'
|
||||
return f"""
|
||||
<div
|
||||
class="flowchart-panel"
|
||||
data-html5-flowchart
|
||||
hx-get="{hx_url}"
|
||||
{live_attr}
|
||||
hx-swap="outerHTML"
|
||||
{oob_attr}
|
||||
>
|
||||
<div class="panel-title">Карта связей · {escape(mode)}</div>
|
||||
{_flowchart_depth_actions(project_id, focus, normalized_depth)}
|
||||
<dl class="report-grid">
|
||||
{_metric("Nodes", len(nodes))}
|
||||
{_metric("Edges", len(edges))}
|
||||
{_metric("Total nodes", getattr(flowchart, "total_nodes", 0))}
|
||||
{_metric("Total edges", getattr(flowchart, "total_edges", 0))}
|
||||
</dl>
|
||||
<div class="compact-list">{body}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def render_html5_project_report(project_id: str, report: dict | None) -> str:
|
||||
if report is None:
|
||||
return f"""
|
||||
<div
|
||||
class="report-panel"
|
||||
data-html5-project-report
|
||||
hx-get="/html5/projects/{quote(project_id)}/report"
|
||||
hx-trigger="load"
|
||||
sse-swap="project-report"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="panel-title">Отчет проекта</div>
|
||||
<p class="muted padded">Сервер готовит сводку проекта.</p>
|
||||
</div>
|
||||
"""
|
||||
metrics = [
|
||||
("Objects", report.get("node_count", 0)),
|
||||
("Edges", report.get("edge_count", 0)),
|
||||
("Procedures", report.get("procedure_count", 0)),
|
||||
("Queries", report.get("query_count", 0)),
|
||||
("Writes", report.get("write_count", 0)),
|
||||
("Roles", report.get("role_count", 0)),
|
||||
("Unowned", report.get("unowned_object_count", 0)),
|
||||
("Sensitive", report.get("unclassified_sensitive_count", 0)),
|
||||
]
|
||||
return f"""
|
||||
<div
|
||||
class="report-panel"
|
||||
data-html5-project-report
|
||||
hx-get="/html5/projects/{quote(project_id)}/report"
|
||||
sse-swap="project-report"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="panel-title">Отчет проекта</div>
|
||||
{_project_summary(report)}
|
||||
<dl class="report-grid">{''.join(_metric(label, value) for label, value in metrics)}</dl>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _project_summary(report: dict) -> str:
|
||||
objects = int(report.get("node_count", 0) or 0)
|
||||
procedures = int(report.get("procedure_count", 0) or 0)
|
||||
queries = int(report.get("query_count", 0) or 0)
|
||||
writes = int(report.get("write_count", 0) or 0)
|
||||
unowned = int(report.get("unowned_object_count", 0) or 0)
|
||||
sensitive = int(report.get("unclassified_sensitive_count", 0) or 0)
|
||||
risk_total = unowned + sensitive
|
||||
bits = [
|
||||
f"{objects} objects",
|
||||
f"{procedures} procedures",
|
||||
f"{queries} queries",
|
||||
f"{writes} writes",
|
||||
f"{risk_total} risk signals",
|
||||
]
|
||||
if unowned:
|
||||
bits.append(f"{unowned} unowned")
|
||||
if sensitive:
|
||||
bits.append(f"{sensitive} sensitive")
|
||||
return f"""
|
||||
<p class="project-summary" data-html5-project-summary>
|
||||
{escape(" · ".join(bits))}
|
||||
</p>
|
||||
"""
|
||||
|
||||
|
||||
def render_html5_object_report(
|
||||
project_id: str,
|
||||
impact: object,
|
||||
*,
|
||||
access: object | None = None,
|
||||
privacy: object | None = None,
|
||||
runtime: Iterable[object] | None = None,
|
||||
integrations: Iterable[object] | None = None,
|
||||
oob: bool = False,
|
||||
) -> str:
|
||||
obj = getattr(impact, "object", None)
|
||||
name = getattr(obj, "qualified_name", None) or getattr(obj, "name", None) or getattr(impact, "object_name", "object")
|
||||
grants = getattr(access, "grants", []) if access is not None else []
|
||||
markers = getattr(privacy, "markers", []) if privacy is not None else []
|
||||
runtime_items = list(runtime or [])
|
||||
integration_items = list(integrations or [])
|
||||
metrics = [
|
||||
("Routines", len(getattr(impact, "routines", []) or [])),
|
||||
("Commands", len(getattr(impact, "commands", []) or [])),
|
||||
("Reads", len(getattr(impact, "query_tables", []) or [])),
|
||||
("Writes", len(getattr(impact, "writes", []) or [])),
|
||||
("Roles", len(grants) or len(getattr(impact, "roles", []) or [])),
|
||||
("Runtime", len(runtime_items)),
|
||||
("Privacy", len(markers)),
|
||||
("Integrations", len(integration_items)),
|
||||
]
|
||||
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
|
||||
return f"""
|
||||
<div
|
||||
class="report-panel"
|
||||
data-html5-project-report
|
||||
{oob_attr}
|
||||
>
|
||||
<div class="panel-title">Отчет объекта</div>
|
||||
<article class="object-focus">
|
||||
<strong>{escape(str(name))}</strong>
|
||||
<span>server focused summary</span>
|
||||
</article>
|
||||
{_object_report_summary(
|
||||
len(getattr(impact, "routines", []) or []),
|
||||
len(getattr(impact, "commands", []) or []),
|
||||
len(getattr(impact, "query_tables", []) or []),
|
||||
len(getattr(impact, "writes", []) or []),
|
||||
len(grants) or len(getattr(impact, "roles", []) or []),
|
||||
len(runtime_items),
|
||||
len(markers),
|
||||
len(integration_items),
|
||||
)}
|
||||
<dl class="report-grid">{''.join(_metric(label, value) for label, value in metrics)}</dl>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _object_report_summary(
|
||||
routines: int,
|
||||
commands: int,
|
||||
reads: int,
|
||||
writes: int,
|
||||
roles: int,
|
||||
runtime: int,
|
||||
privacy: int,
|
||||
integrations: int,
|
||||
) -> str:
|
||||
impact_links = reads + writes
|
||||
bits = [
|
||||
f"{routines} routines",
|
||||
f"{commands} commands",
|
||||
f"{impact_links} data links",
|
||||
f"{roles} roles",
|
||||
]
|
||||
if runtime:
|
||||
bits.append(f"{runtime} runtime signals")
|
||||
if privacy:
|
||||
bits.append(f"{privacy} privacy markers")
|
||||
if integrations:
|
||||
bits.append(f"{integrations} integrations")
|
||||
return f"""
|
||||
<p class="object-report-summary" data-html5-object-report-summary>
|
||||
{escape(" · ".join(bits))}
|
||||
</p>
|
||||
"""
|
||||
|
||||
|
||||
def render_html5_review(
|
||||
project_id: str,
|
||||
findings: list[dict] | None,
|
||||
*,
|
||||
title: str = "Review",
|
||||
oob: bool = False,
|
||||
) -> str:
|
||||
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
|
||||
live_attr = ' sse-swap="project-review"' if title == "Review" and not oob else ""
|
||||
if findings is None:
|
||||
return f"""
|
||||
<div
|
||||
class="review-panel"
|
||||
data-html5-review
|
||||
hx-get="/html5/projects/{quote(project_id)}/review"
|
||||
hx-trigger="load"
|
||||
{live_attr}
|
||||
hx-swap="outerHTML"
|
||||
{oob_attr}
|
||||
>
|
||||
<div class="panel-title">{escape(title)}</div>
|
||||
<p class="muted padded">Сервер готовит findings.</p>
|
||||
</div>
|
||||
"""
|
||||
if not findings:
|
||||
body = '<p class="muted padded">Findings не найдены</p>'
|
||||
else:
|
||||
body = "".join(_review_item(project_id, finding) for finding in findings[:12])
|
||||
return f"""
|
||||
<div
|
||||
class="review-panel"
|
||||
data-html5-review
|
||||
hx-get="/html5/projects/{quote(project_id)}/review"
|
||||
{live_attr}
|
||||
hx-swap="outerHTML"
|
||||
{oob_attr}
|
||||
>
|
||||
<div class="panel-title">{escape(title)} · {len(findings)}</div>
|
||||
{_review_summary(findings)}
|
||||
<div class="review-list">{body}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def render_html5_object_context(
|
||||
project_id: str,
|
||||
schema: object | None,
|
||||
impact: object | None,
|
||||
access: object | None = None,
|
||||
ui: object | None = None,
|
||||
runtime: Iterable[object] | None = None,
|
||||
knowledge: Iterable[object] | None = None,
|
||||
privacy: object | None = None,
|
||||
integrations: Iterable[object] | None = None,
|
||||
flowchart: object | None = None,
|
||||
mode: str = "overview",
|
||||
) -> str:
|
||||
if schema is None or impact is None:
|
||||
return f"""
|
||||
<div class="object-context" data-html5-object-context>
|
||||
<div class="panel-title">Object context</div>
|
||||
<p class="muted padded">Выберите объект метаданных, чтобы сервер собрал schema/impact контекст проекта {escape(project_id)}.</p>
|
||||
</div>
|
||||
"""
|
||||
obj = getattr(schema, "object", None) or getattr(impact, "object", None)
|
||||
name = getattr(obj, "qualified_name", None) or getattr(obj, "name", None) or "object"
|
||||
attributes = getattr(schema, "attributes", []) or []
|
||||
sections = getattr(schema, "tabular_sections", []) or []
|
||||
modules = getattr(impact, "modules", []) or []
|
||||
routines = getattr(impact, "routines", []) or []
|
||||
forms = getattr(impact, "forms", []) or []
|
||||
commands = getattr(impact, "commands", []) or []
|
||||
roles = getattr(impact, "roles", []) or []
|
||||
jobs = getattr(impact, "jobs", []) or []
|
||||
callees = getattr(impact, "callees", []) or []
|
||||
query_tables = getattr(impact, "query_tables", []) or []
|
||||
writes = getattr(impact, "writes", []) or []
|
||||
grants = getattr(access, "grants", []) if access is not None else []
|
||||
ui_forms = getattr(ui, "forms", []) if ui is not None else []
|
||||
runtime_items = list(runtime or [])
|
||||
knowledge_items = list(knowledge or [])
|
||||
privacy_markers = getattr(privacy, "markers", []) if privacy is not None else []
|
||||
integration_items = list(integrations or [])
|
||||
flow_nodes = getattr(flowchart, "nodes", []) if flowchart is not None else []
|
||||
flow_edges = getattr(flowchart, "edges", []) if flowchart is not None else []
|
||||
normalized_mode = mode if mode in {"overview", "schema", "impact", "privacy"} else "overview"
|
||||
if normalized_mode == "schema":
|
||||
compact_body = (
|
||||
''.join(_named_node_item("attr", item) for item in attributes[:12])
|
||||
or '<p class="muted padded">Реквизиты не найдены</p>'
|
||||
)
|
||||
compact_body += ''.join(_tabular_section_item(item) for item in sections[:8])
|
||||
compact_body += ''.join(_ui_form_item(item) for item in ui_forms[:8])
|
||||
elif normalized_mode == "impact":
|
||||
compact_body = ''.join(_integration_endpoint_item(item) for item in integration_items[:8])
|
||||
compact_body += ''.join(_named_node_item("command", item) for item in commands[:8])
|
||||
compact_body += ''.join(_named_node_item("read", item) for item in query_tables[:8])
|
||||
compact_body += ''.join(_named_node_item("write", item) for item in writes[:8])
|
||||
compact_body += ''.join(_named_node_item("call", item) for item in callees[:8])
|
||||
compact_body += ''.join(_flowchart_edge_item(project_id, item, flow_nodes, 1) for item in flow_edges[:8])
|
||||
compact_body += ''.join(_named_node_item("routine", item) for item in routines[:8])
|
||||
compact_body += ''.join(_named_node_item("job", item) for item in jobs[:6])
|
||||
compact_body = compact_body or '<p class="muted padded">Impact-связи не найдены</p>'
|
||||
elif normalized_mode == "privacy":
|
||||
compact_body = ''.join(_role_access_item(item) for item in grants[:12])
|
||||
compact_body += ''.join(_privacy_marker_item(item) for item in privacy_markers[:12])
|
||||
compact_body = compact_body or '<p class="muted padded">Доступы и privacy-маркеры не найдены</p>'
|
||||
else:
|
||||
compact_body = (
|
||||
''.join(_named_node_item("attr", item) for item in attributes[:6])
|
||||
or '<p class="muted padded">Реквизиты не найдены</p>'
|
||||
)
|
||||
compact_body += ''.join(_tabular_section_item(item) for item in sections[:4])
|
||||
compact_body += ''.join(_ui_form_item(item) for item in ui_forms[:4])
|
||||
compact_body += ''.join(_role_access_item(item) for item in grants[:6])
|
||||
compact_body += ''.join(_integration_endpoint_item(item) for item in integration_items[:4])
|
||||
compact_body += ''.join(_named_node_item("command", item) for item in commands[:6])
|
||||
compact_body += ''.join(_named_node_item("read", item) for item in query_tables[:4])
|
||||
compact_body += ''.join(_named_node_item("write", item) for item in writes[:4])
|
||||
compact_body += ''.join(_named_node_item("call", item) for item in callees[:6])
|
||||
compact_body += ''.join(_flowchart_edge_item(project_id, item, flow_nodes, 1) for item in flow_edges[:8])
|
||||
compact_body += ''.join(_runtime_summary_item(item) for item in runtime_items[:6])
|
||||
compact_body += ''.join(_knowledge_record_item(item) for item in knowledge_items[:6])
|
||||
compact_body += ''.join(_privacy_marker_item(item) for item in privacy_markers[:6])
|
||||
compact_body += ''.join(_named_node_item("routine", item) for item in routines[:6])
|
||||
compact_body += ''.join(_named_node_item("job", item) for item in jobs[:4])
|
||||
return f"""
|
||||
<div class="object-context" data-html5-object-context data-html5-object-name="{escape(str(name))}" data-html5-object-mode="{escape(normalized_mode)}">
|
||||
<div class="panel-title">Object context · {escape(normalized_mode)}</div>
|
||||
{_object_breadcrumb(str(name))}
|
||||
<article class="object-focus">
|
||||
<strong>{escape(str(name))}</strong>
|
||||
<span>{escape(str(getattr(obj, "kind", "object")))}</span>
|
||||
</article>
|
||||
{_object_action_links(project_id, str(name), getattr(obj, "lineage_id", ""), modules, normalized_mode)}
|
||||
{_object_summary(
|
||||
len(attributes),
|
||||
len(sections),
|
||||
len(commands),
|
||||
len(query_tables),
|
||||
len(writes),
|
||||
len(callees),
|
||||
len(integration_items),
|
||||
len(grants) or len(roles),
|
||||
len(runtime_items),
|
||||
len(privacy_markers),
|
||||
)}
|
||||
<dl class="report-grid">
|
||||
{_metric("Attrs", len(attributes))}
|
||||
{_metric("Tables", len(sections))}
|
||||
{_metric("Modules", len(modules))}
|
||||
{_metric("Routines", len(routines))}
|
||||
{_metric("Forms", len(ui_forms) or len(forms))}
|
||||
{_metric("Commands", len(commands))}
|
||||
{_metric("Roles", len(grants) or len(roles))}
|
||||
{_metric("Reads", len(query_tables))}
|
||||
{_metric("Writes", len(writes))}
|
||||
{_metric("Calls", len(callees))}
|
||||
{_metric("Integrations", len(integration_items))}
|
||||
{_metric("Graph nodes", len(flow_nodes))}
|
||||
{_metric("Graph edges", len(flow_edges))}
|
||||
{_metric("Runtime", len(runtime_items))}
|
||||
{_metric("Knowledge", len(knowledge_items))}
|
||||
{_metric("Privacy", len(privacy_markers))}
|
||||
</dl>
|
||||
<div class="compact-list">
|
||||
{compact_body}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _page(title: str, body: str) -> str:
|
||||
return f"""<!doctype html>
|
||||
<html lang="ru">
|
||||
@@ -545,403 +142,6 @@ def _metric(label: str, value: object) -> str:
|
||||
return f"<div><dt>{escape(label)}</dt><dd>{escape(str(value))}</dd></div>"
|
||||
|
||||
|
||||
def _review_summary(findings: list[dict]) -> str:
|
||||
severities = Counter(str(item.get("severity") or item.get("level") or "INFO") for item in findings)
|
||||
titles = Counter(str(item.get("title") or item.get("code") or "Finding") for item in findings)
|
||||
severity_text = ", ".join(f"{name}: {count}" for name, count in sorted(severities.items())) or "no severities"
|
||||
title_text = ", ".join(f"{name}: {count}" for name, count in sorted(titles.items())[:4]) or "no findings"
|
||||
return f"""
|
||||
<p class="review-summary" data-html5-review-summary>
|
||||
{escape(str(len(findings)))} findings · {escape(severity_text)} · {escape(title_text)}
|
||||
</p>
|
||||
"""
|
||||
|
||||
|
||||
def _review_item(project_id: str, finding: dict) -> str:
|
||||
title = str(finding.get("title") or finding.get("code") or "Finding")
|
||||
severity = str(finding.get("severity") or finding.get("level") or "INFO")
|
||||
message = str(finding.get("message") or finding.get("description") or "")
|
||||
source_path = str(finding.get("source_path") or finding.get("path") or "")
|
||||
line = finding.get("line_start") or finding.get("line")
|
||||
location = f"{source_path}:{line}" if source_path and line else source_path
|
||||
source_link = (
|
||||
f"""
|
||||
<a
|
||||
href="/html5/projects/{quote(project_id)}/source/by-path?path={quote(source_path, safe='')}"
|
||||
hx-get="/html5/projects/{quote(project_id)}/source/by-path?path={quote(source_path, safe='')}"
|
||||
hx-target="[data-html5-source]"
|
||||
hx-swap="outerHTML"
|
||||
data-html5-review-source="{escape(source_path)}"
|
||||
>Source</a>
|
||||
"""
|
||||
if source_path
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
<article class="review-item" data-html5-review-finding="{escape(severity)}">
|
||||
<strong>{escape(title)}</strong>
|
||||
<span>{escape(severity)}</span>
|
||||
<small>{escape(message or location or "no details")}</small>
|
||||
<span class="inline-actions">{source_link}</span>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _named_node_item(label: str, node: object) -> str:
|
||||
name = getattr(node, "qualified_name", None) or getattr(node, "name", "")
|
||||
kind = getattr(node, "kind", label)
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="{escape(label)}">
|
||||
<strong>{escape(str(name))}</strong>
|
||||
<small>{escape(str(kind))}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _object_breadcrumb(object_name: str) -> str:
|
||||
parts = [part for part in object_name.split(".") if part]
|
||||
if not parts:
|
||||
return ""
|
||||
items = "".join(f"<span>{escape(part)}</span>" for part in parts)
|
||||
return f'<nav class="object-breadcrumb" data-html5-object-breadcrumb>{items}</nav>'
|
||||
|
||||
|
||||
def _object_summary(
|
||||
attributes: int,
|
||||
sections: int,
|
||||
commands: int,
|
||||
reads: int,
|
||||
writes: int,
|
||||
calls: int,
|
||||
integrations: int,
|
||||
access_rules: int,
|
||||
runtime_signals: int,
|
||||
privacy_markers: int,
|
||||
) -> str:
|
||||
impact_total = reads + writes + calls
|
||||
status_bits = [
|
||||
f"{attributes} attrs",
|
||||
f"{sections} tables",
|
||||
f"{commands} commands",
|
||||
f"{impact_total} impact links",
|
||||
f"{access_rules} access rules",
|
||||
]
|
||||
if integrations:
|
||||
status_bits.append(f"{integrations} integrations")
|
||||
if runtime_signals:
|
||||
status_bits.append(f"{runtime_signals} runtime signals")
|
||||
if privacy_markers:
|
||||
status_bits.append(f"{privacy_markers} privacy markers")
|
||||
return f"""
|
||||
<p class="object-summary" data-html5-object-summary>
|
||||
{escape(" · ".join(status_bits))}
|
||||
</p>
|
||||
"""
|
||||
|
||||
|
||||
def _object_action_links(
|
||||
project_id: str,
|
||||
object_name: str,
|
||||
lineage_id: object,
|
||||
modules: Iterable[object],
|
||||
active_mode: str,
|
||||
) -> str:
|
||||
quoted_project = quote(project_id)
|
||||
quoted_object = quote(object_name, safe="")
|
||||
lineage = str(lineage_id or "")
|
||||
first_module = next(iter(modules), None)
|
||||
module_lineage = str(getattr(first_module, "lineage_id", "") or "")
|
||||
source_link = (
|
||||
f'<a class="button" href="/html5/projects/{quoted_project}/source/{quote(module_lineage, safe="")}" '
|
||||
'hx-get="/html5/projects/{project}/source/{module}" hx-target="[data-html5-source]" hx-swap="outerHTML">Source</a>'.format(
|
||||
project=quoted_project,
|
||||
module=quote(module_lineage, safe=""),
|
||||
)
|
||||
if module_lineage
|
||||
else ""
|
||||
)
|
||||
symbol_link = (
|
||||
f'<a class="button" href="/html5/projects/{quoted_project}/symbols/{quote(lineage, safe="")}/detail" '
|
||||
'hx-get="/html5/projects/{project}/symbols/{lineage}/detail" hx-target="[data-html5-symbol-detail]" hx-swap="outerHTML">Symbol</a>'.format(
|
||||
project=quoted_project,
|
||||
lineage=quote(lineage, safe=""),
|
||||
)
|
||||
if lineage
|
||||
else ""
|
||||
)
|
||||
def active_attrs(mode: str) -> str:
|
||||
return ' aria-current="page" data-html5-object-action-active="true"' if mode == active_mode else ""
|
||||
|
||||
return f"""
|
||||
<nav class="object-actions" data-html5-object-actions>
|
||||
<a
|
||||
class="button"
|
||||
href="/html5/projects/{quoted_project}/objects/context/{quoted_object}"
|
||||
hx-get="/html5/projects/{quoted_project}/objects/context/{quoted_object}"
|
||||
hx-target="[data-html5-object-context]"
|
||||
hx-swap="outerHTML"
|
||||
{active_attrs("overview")}
|
||||
>Overview</a>
|
||||
<a
|
||||
class="button"
|
||||
href="/projects/{quoted_project}/objects/schema/{quoted_object}"
|
||||
hx-get="/html5/projects/{quoted_project}/objects/context/{quoted_object}?mode=schema"
|
||||
hx-target="[data-html5-object-context]"
|
||||
hx-swap="outerHTML"
|
||||
{active_attrs("schema")}
|
||||
>Schema</a>
|
||||
<a
|
||||
class="button"
|
||||
href="/projects/{quoted_project}/objects/impact/{quoted_object}"
|
||||
hx-get="/html5/projects/{quoted_project}/objects/context/{quoted_object}?mode=impact"
|
||||
hx-target="[data-html5-object-context]"
|
||||
hx-swap="outerHTML"
|
||||
{active_attrs("impact")}
|
||||
>Impact</a>
|
||||
<a
|
||||
class="button"
|
||||
href="/projects/{quoted_project}/objects/privacy/{quoted_object}"
|
||||
hx-get="/html5/projects/{quoted_project}/objects/context/{quoted_object}?mode=privacy"
|
||||
hx-target="[data-html5-object-context]"
|
||||
hx-swap="outerHTML"
|
||||
{active_attrs("privacy")}
|
||||
>Privacy</a>
|
||||
<a
|
||||
class="button"
|
||||
href="/html5/projects/{quoted_project}/flowchart?focus={quoted_object}&depth=1"
|
||||
hx-get="/html5/projects/{quoted_project}/flowchart?focus={quoted_object}&depth=1"
|
||||
hx-target="[data-html5-flowchart]"
|
||||
hx-swap="outerHTML"
|
||||
>Flowchart</a>
|
||||
{source_link}
|
||||
{symbol_link}
|
||||
</nav>
|
||||
"""
|
||||
|
||||
|
||||
def _tabular_section_item(section: object) -> str:
|
||||
tabular_section = getattr(section, "tabular_section", None)
|
||||
columns = getattr(section, "columns", []) or []
|
||||
name = getattr(tabular_section, "qualified_name", None) or getattr(tabular_section, "name", "")
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="tabular-section">
|
||||
<strong>{escape(str(name))}</strong>
|
||||
<small>{escape(str(len(columns)))} columns</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _role_access_item(grant: object) -> str:
|
||||
role = getattr(grant, "role", None)
|
||||
permissions = getattr(grant, "permissions", {}) or {}
|
||||
role_name = getattr(role, "qualified_name", None) or getattr(role, "name", "role")
|
||||
enabled = [
|
||||
str(key)
|
||||
for key, value in sorted(permissions.items())
|
||||
if str(value).lower() in {"true", "1", "yes", "да"}
|
||||
]
|
||||
permission_text = ", ".join(enabled) if enabled else "permissions unavailable"
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="role-access">
|
||||
<strong>{escape(str(role_name))}</strong>
|
||||
<small>{escape(permission_text)}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _ui_form_item(form_semantics: object) -> str:
|
||||
form = getattr(form_semantics, "form", None)
|
||||
commands = getattr(form_semantics, "commands", []) or []
|
||||
elements = getattr(form_semantics, "elements", []) or []
|
||||
handlers = getattr(form_semantics, "command_handlers", {}) or {}
|
||||
form_name = getattr(form, "qualified_name", None) or getattr(form, "name", "form")
|
||||
command_names = [
|
||||
str(getattr(command, "name", getattr(command, "qualified_name", "")))
|
||||
for command in commands[:3]
|
||||
]
|
||||
handler_names = [
|
||||
str(getattr(handler, "name", getattr(handler, "qualified_name", "")))
|
||||
for handler in list(handlers.values())[:3]
|
||||
]
|
||||
details = []
|
||||
if command_names:
|
||||
details.append("cmd: " + ", ".join(command_names))
|
||||
if handler_names:
|
||||
details.append("handler: " + ", ".join(handler_names))
|
||||
if elements:
|
||||
details.append(f"{len(elements)} elements")
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="ui-form">
|
||||
<strong>{escape(str(form_name))}</strong>
|
||||
<small>{escape(" · ".join(details) or "UI metadata")}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _runtime_summary_item(item: object) -> str:
|
||||
node = getattr(item, "node", None)
|
||||
name = getattr(node, "qualified_name", None) or getattr(node, "name", "runtime")
|
||||
signal_count = getattr(item, "signal_count", 0)
|
||||
error_count = getattr(item, "error_count", 0)
|
||||
max_duration = getattr(item, "max_duration_ms", None)
|
||||
duration_text = f" · max {max_duration} ms" if max_duration is not None else ""
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="runtime">
|
||||
<strong>{escape(str(name))}</strong>
|
||||
<small>{escape(str(signal_count))} signals · {escape(str(error_count))} errors{escape(duration_text)}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _knowledge_record_item(record: object) -> str:
|
||||
title = str(getattr(record, "title", "knowledge"))
|
||||
scope = _enum_text(getattr(record, "scope", ""))
|
||||
body = str(getattr(record, "body", "") or "")
|
||||
record_id = str(getattr(record, "record_id", ""))
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="knowledge">
|
||||
<strong>{escape(title)}</strong>
|
||||
<small>{escape(scope)} · {escape(record_id)} · {escape(body[:120])}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _privacy_marker_item(marker: object) -> str:
|
||||
classification = _enum_text(getattr(marker, "classification", ""))
|
||||
reason = str(getattr(marker, "reason", "") or "")
|
||||
target_id = str(getattr(marker, "target_id", "") or "target unavailable")
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="privacy">
|
||||
<strong>{escape(classification or "privacy")}</strong>
|
||||
<small>{escape(reason or target_id)}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _integration_endpoint_item(endpoint: object) -> str:
|
||||
name = str(getattr(endpoint, "name", "") or "integration")
|
||||
kind = _enum_text(getattr(endpoint, "kind", "UNKNOWN"))
|
||||
direction = str(getattr(endpoint, "direction", "UNKNOWN") or "UNKNOWN")
|
||||
owner = str(getattr(endpoint, "owner", "") or "owner unavailable")
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="integration">
|
||||
<strong>{escape(name)}</strong>
|
||||
<small>{escape(kind)} · {escape(direction)} · {escape(owner)}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _object_context_url(project_id: str, name: str) -> str:
|
||||
return f"/html5/projects/{quote(project_id)}/objects/context/{quote(name, safe='')}"
|
||||
|
||||
|
||||
def _flowchart_focus_link(project_id: str, name: str, depth: int) -> str:
|
||||
url = _flowchart_url(project_id, name, depth)
|
||||
return f"""
|
||||
<a
|
||||
href="{url}"
|
||||
hx-get="{url}"
|
||||
hx-target="[data-html5-flowchart]"
|
||||
hx-swap="outerHTML"
|
||||
data-html5-flowchart-focus="{escape(name)}"
|
||||
>{escape(name)}</a>
|
||||
"""
|
||||
|
||||
|
||||
def _flowchart_context_link(project_id: str, name: str, kind: str) -> str:
|
||||
if kind not in _HTML5_OBJECT_CONTEXT_KINDS:
|
||||
return ""
|
||||
url = _object_context_url(project_id, name)
|
||||
return f"""
|
||||
<a
|
||||
href="{url}"
|
||||
hx-get="{url}"
|
||||
hx-target="[data-html5-object-context]"
|
||||
hx-swap="outerHTML"
|
||||
data-html5-flowchart-context="{escape(name)}"
|
||||
>Context</a>
|
||||
"""
|
||||
|
||||
|
||||
def _flowchart_edge_item(project_id: str, edge: object, nodes: Iterable[object], depth: int) -> str:
|
||||
node_names = {
|
||||
str(getattr(node, "id", "")): str(getattr(node, "qualified_name", "") or getattr(node, "label", ""))
|
||||
for node in nodes
|
||||
}
|
||||
node_kinds = {
|
||||
str(getattr(node, "id", "")): str(getattr(node, "kind", "") or "NODE")
|
||||
for node in nodes
|
||||
}
|
||||
source = node_names.get(str(getattr(edge, "source", "")), str(getattr(edge, "source", "")))
|
||||
target = node_names.get(str(getattr(edge, "target", "")), str(getattr(edge, "target", "")))
|
||||
source_kind = node_kinds.get(str(getattr(edge, "source", "")), "")
|
||||
target_kind = node_kinds.get(str(getattr(edge, "target", "")), "")
|
||||
label = str(getattr(edge, "label", "") or getattr(edge, "kind", "") or "link")
|
||||
kind = str(getattr(edge, "kind", "") or "FLOW")
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="flow-edge">
|
||||
<strong>{escape(label)}</strong>
|
||||
<small>{_flowchart_focus_link(project_id, source, depth)} -> {_flowchart_focus_link(project_id, target, depth)} · {escape(kind)}</small>
|
||||
<span class="inline-actions">
|
||||
{_flowchart_context_link(project_id, source, source_kind)}
|
||||
{_flowchart_context_link(project_id, target, target_kind)}
|
||||
</span>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _flowchart_url(project_id: str, focus: str | None, depth: int) -> str:
|
||||
params = []
|
||||
if focus:
|
||||
params.append(f"focus={quote(focus, safe='')}")
|
||||
params.append(f"depth={depth}")
|
||||
return f"/html5/projects/{quote(project_id)}/flowchart?{'&'.join(params)}"
|
||||
|
||||
|
||||
def _flowchart_depth_actions(project_id: str, focus: str | None, active_depth: int) -> str:
|
||||
buttons = []
|
||||
for depth in [1, 2, 3]:
|
||||
active = ' aria-current="page" data-html5-object-action-active="true"' if depth == active_depth else ""
|
||||
url = _flowchart_url(project_id, focus, depth)
|
||||
buttons.append(
|
||||
f"""
|
||||
<a
|
||||
class="button"
|
||||
href="{url}"
|
||||
hx-get="{url}"
|
||||
hx-target="[data-html5-flowchart]"
|
||||
hx-swap="outerHTML"
|
||||
{active}
|
||||
>Depth {depth}</a>
|
||||
"""
|
||||
)
|
||||
return f'<nav class="object-actions" data-html5-flowchart-actions>{"".join(buttons)}</nav>'
|
||||
|
||||
|
||||
def _flowchart_node_item(project_id: str, node: object, depth: int) -> str:
|
||||
name = str(getattr(node, "qualified_name", "") or getattr(node, "label", "") or "node")
|
||||
kind = str(getattr(node, "kind", "") or "NODE")
|
||||
level = getattr(node, "level", 0)
|
||||
count = getattr(node, "count", 1)
|
||||
url = _flowchart_url(project_id, name, depth)
|
||||
return f"""
|
||||
<article
|
||||
class="object-context-item"
|
||||
data-html5-flowchart-node="{escape(kind)}"
|
||||
data-html5-flowchart-focus="{escape(name)}"
|
||||
hx-get="{url}"
|
||||
hx-target="[data-html5-flowchart]"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<strong>{escape(name)}</strong>
|
||||
<small>{escape(kind)} · level {escape(str(level))} · count {escape(str(count))}</small>
|
||||
<span class="inline-actions">
|
||||
{_flowchart_context_link(project_id, name, kind)}
|
||||
</span>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return """
|
||||
:root{color-scheme:light;--bg:#f7f8fb;--card:#fff;--text:#17202a;--muted:#687385;--line:#dce2ea;--brand:#2457d6;--ok:#168457;--warn:#a16207}
|
||||
|
||||
@@ -11,6 +11,8 @@ from api_server.html5 import (
|
||||
_page,
|
||||
_project_link,
|
||||
_topbar,
|
||||
)
|
||||
from api_server.html5_inspector import (
|
||||
render_html5_flowchart,
|
||||
render_html5_object_context,
|
||||
render_html5_project_report,
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from html import escape
|
||||
from typing import Iterable
|
||||
from urllib.parse import quote
|
||||
|
||||
from api_server.html5 import _enum_text, _metric
|
||||
from sir import NodeKind
|
||||
|
||||
_HTML5_OBJECT_CONTEXT_KINDS = {
|
||||
NodeKind.CATALOG.value,
|
||||
NodeKind.DOCUMENT.value,
|
||||
NodeKind.REGISTER.value,
|
||||
NodeKind.COMMON_MODULE.value,
|
||||
NodeKind.CONSTANT.value,
|
||||
NodeKind.DOCUMENT_JOURNAL.value,
|
||||
NodeKind.ENUM.value,
|
||||
NodeKind.REPORT.value,
|
||||
NodeKind.DATA_PROCESSOR.value,
|
||||
NodeKind.CHART_OF_CHARACTERISTIC_TYPES.value,
|
||||
NodeKind.CHART_OF_ACCOUNTS.value,
|
||||
NodeKind.CHART_OF_CALCULATION_TYPES.value,
|
||||
NodeKind.EXCHANGE_PLAN.value,
|
||||
NodeKind.EXTERNAL_DATA_SOURCE.value,
|
||||
NodeKind.SCHEDULED_JOB.value,
|
||||
NodeKind.BUSINESS_PROCESS.value,
|
||||
NodeKind.TASK.value,
|
||||
}
|
||||
if hasattr(NodeKind, "EVENT_SUBSCRIPTION"):
|
||||
_HTML5_OBJECT_CONTEXT_KINDS.add(NodeKind.EVENT_SUBSCRIPTION.value)
|
||||
|
||||
|
||||
def render_html5_flowchart(
|
||||
project_id: str,
|
||||
flowchart: object | None,
|
||||
*,
|
||||
focus: str | None = None,
|
||||
depth: int = 1,
|
||||
oob: bool = False,
|
||||
) -> str:
|
||||
normalized_depth = min(max(depth, 1), 3)
|
||||
hx_url = _flowchart_url(project_id, focus, normalized_depth)
|
||||
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
|
||||
live_attr = ' sse-swap="project-flowchart"' if focus is None else ""
|
||||
if flowchart is None:
|
||||
return f"""
|
||||
<div
|
||||
class="flowchart-panel"
|
||||
data-html5-flowchart
|
||||
hx-get="{hx_url}"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
{oob_attr}
|
||||
{live_attr}
|
||||
>
|
||||
<div class="panel-title">Карта связей</div>
|
||||
<p class="muted padded">Сервер собирает граф проекта.</p>
|
||||
</div>
|
||||
"""
|
||||
nodes = getattr(flowchart, "nodes", []) or []
|
||||
edges = getattr(flowchart, "edges", []) or []
|
||||
mode = str(getattr(flowchart, "mode", "overview"))
|
||||
body = "".join(_flowchart_edge_item(project_id, item, nodes, normalized_depth) for item in edges[:10])
|
||||
if not body:
|
||||
body = "".join(_flowchart_node_item(project_id, item, normalized_depth) for item in nodes[:10])
|
||||
if not body:
|
||||
body = '<p class="muted padded">Связи проекта не найдены</p>'
|
||||
return f"""
|
||||
<div
|
||||
class="flowchart-panel"
|
||||
data-html5-flowchart
|
||||
hx-get="{hx_url}"
|
||||
{live_attr}
|
||||
hx-swap="outerHTML"
|
||||
{oob_attr}
|
||||
>
|
||||
<div class="panel-title">Карта связей · {escape(mode)}</div>
|
||||
{_flowchart_depth_actions(project_id, focus, normalized_depth)}
|
||||
<dl class="report-grid">
|
||||
{_metric("Nodes", len(nodes))}
|
||||
{_metric("Edges", len(edges))}
|
||||
{_metric("Total nodes", getattr(flowchart, "total_nodes", 0))}
|
||||
{_metric("Total edges", getattr(flowchart, "total_edges", 0))}
|
||||
</dl>
|
||||
<div class="compact-list">{body}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def render_html5_project_report(project_id: str, report: dict | None) -> str:
|
||||
if report is None:
|
||||
return f"""
|
||||
<div
|
||||
class="report-panel"
|
||||
data-html5-project-report
|
||||
hx-get="/html5/projects/{quote(project_id)}/report"
|
||||
hx-trigger="load"
|
||||
sse-swap="project-report"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="panel-title">Отчет проекта</div>
|
||||
<p class="muted padded">Сервер готовит сводку проекта.</p>
|
||||
</div>
|
||||
"""
|
||||
metrics = [
|
||||
("Objects", report.get("node_count", 0)),
|
||||
("Edges", report.get("edge_count", 0)),
|
||||
("Procedures", report.get("procedure_count", 0)),
|
||||
("Queries", report.get("query_count", 0)),
|
||||
("Writes", report.get("write_count", 0)),
|
||||
("Roles", report.get("role_count", 0)),
|
||||
("Unowned", report.get("unowned_object_count", 0)),
|
||||
("Sensitive", report.get("unclassified_sensitive_count", 0)),
|
||||
]
|
||||
return f"""
|
||||
<div
|
||||
class="report-panel"
|
||||
data-html5-project-report
|
||||
hx-get="/html5/projects/{quote(project_id)}/report"
|
||||
sse-swap="project-report"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<div class="panel-title">Отчет проекта</div>
|
||||
{_project_summary(report)}
|
||||
<dl class="report-grid">{''.join(_metric(label, value) for label, value in metrics)}</dl>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _project_summary(report: dict) -> str:
|
||||
objects = int(report.get("node_count", 0) or 0)
|
||||
procedures = int(report.get("procedure_count", 0) or 0)
|
||||
queries = int(report.get("query_count", 0) or 0)
|
||||
writes = int(report.get("write_count", 0) or 0)
|
||||
unowned = int(report.get("unowned_object_count", 0) or 0)
|
||||
sensitive = int(report.get("unclassified_sensitive_count", 0) or 0)
|
||||
risk_total = unowned + sensitive
|
||||
bits = [
|
||||
f"{objects} objects",
|
||||
f"{procedures} procedures",
|
||||
f"{queries} queries",
|
||||
f"{writes} writes",
|
||||
f"{risk_total} risk signals",
|
||||
]
|
||||
if unowned:
|
||||
bits.append(f"{unowned} unowned")
|
||||
if sensitive:
|
||||
bits.append(f"{sensitive} sensitive")
|
||||
return f"""
|
||||
<p class="project-summary" data-html5-project-summary>
|
||||
{escape(" · ".join(bits))}
|
||||
</p>
|
||||
"""
|
||||
|
||||
|
||||
def render_html5_object_report(
|
||||
project_id: str,
|
||||
impact: object,
|
||||
*,
|
||||
access: object | None = None,
|
||||
privacy: object | None = None,
|
||||
runtime: Iterable[object] | None = None,
|
||||
integrations: Iterable[object] | None = None,
|
||||
oob: bool = False,
|
||||
) -> str:
|
||||
obj = getattr(impact, "object", None)
|
||||
name = getattr(obj, "qualified_name", None) or getattr(obj, "name", None) or getattr(impact, "object_name", "object")
|
||||
grants = getattr(access, "grants", []) if access is not None else []
|
||||
markers = getattr(privacy, "markers", []) if privacy is not None else []
|
||||
runtime_items = list(runtime or [])
|
||||
integration_items = list(integrations or [])
|
||||
metrics = [
|
||||
("Routines", len(getattr(impact, "routines", []) or [])),
|
||||
("Commands", len(getattr(impact, "commands", []) or [])),
|
||||
("Reads", len(getattr(impact, "query_tables", []) or [])),
|
||||
("Writes", len(getattr(impact, "writes", []) or [])),
|
||||
("Roles", len(grants) or len(getattr(impact, "roles", []) or [])),
|
||||
("Runtime", len(runtime_items)),
|
||||
("Privacy", len(markers)),
|
||||
("Integrations", len(integration_items)),
|
||||
]
|
||||
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
|
||||
return f"""
|
||||
<div
|
||||
class="report-panel"
|
||||
data-html5-project-report
|
||||
{oob_attr}
|
||||
>
|
||||
<div class="panel-title">Отчет объекта</div>
|
||||
<article class="object-focus">
|
||||
<strong>{escape(str(name))}</strong>
|
||||
<span>server focused summary</span>
|
||||
</article>
|
||||
{_object_report_summary(
|
||||
len(getattr(impact, "routines", []) or []),
|
||||
len(getattr(impact, "commands", []) or []),
|
||||
len(getattr(impact, "query_tables", []) or []),
|
||||
len(getattr(impact, "writes", []) or []),
|
||||
len(grants) or len(getattr(impact, "roles", []) or []),
|
||||
len(runtime_items),
|
||||
len(markers),
|
||||
len(integration_items),
|
||||
)}
|
||||
<dl class="report-grid">{''.join(_metric(label, value) for label, value in metrics)}</dl>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _object_report_summary(
|
||||
routines: int,
|
||||
commands: int,
|
||||
reads: int,
|
||||
writes: int,
|
||||
roles: int,
|
||||
runtime: int,
|
||||
privacy: int,
|
||||
integrations: int,
|
||||
) -> str:
|
||||
impact_links = reads + writes
|
||||
bits = [
|
||||
f"{routines} routines",
|
||||
f"{commands} commands",
|
||||
f"{impact_links} data links",
|
||||
f"{roles} roles",
|
||||
]
|
||||
if runtime:
|
||||
bits.append(f"{runtime} runtime signals")
|
||||
if privacy:
|
||||
bits.append(f"{privacy} privacy markers")
|
||||
if integrations:
|
||||
bits.append(f"{integrations} integrations")
|
||||
return f"""
|
||||
<p class="object-report-summary" data-html5-object-report-summary>
|
||||
{escape(" · ".join(bits))}
|
||||
</p>
|
||||
"""
|
||||
|
||||
|
||||
def render_html5_review(
|
||||
project_id: str,
|
||||
findings: list[dict] | None,
|
||||
*,
|
||||
title: str = "Review",
|
||||
oob: bool = False,
|
||||
) -> str:
|
||||
oob_attr = ' hx-swap-oob="outerHTML"' if oob else ""
|
||||
live_attr = ' sse-swap="project-review"' if title == "Review" and not oob else ""
|
||||
if findings is None:
|
||||
return f"""
|
||||
<div
|
||||
class="review-panel"
|
||||
data-html5-review
|
||||
hx-get="/html5/projects/{quote(project_id)}/review"
|
||||
hx-trigger="load"
|
||||
{live_attr}
|
||||
hx-swap="outerHTML"
|
||||
{oob_attr}
|
||||
>
|
||||
<div class="panel-title">{escape(title)}</div>
|
||||
<p class="muted padded">Сервер готовит findings.</p>
|
||||
</div>
|
||||
"""
|
||||
if not findings:
|
||||
body = '<p class="muted padded">Findings не найдены</p>'
|
||||
else:
|
||||
body = "".join(_review_item(project_id, finding) for finding in findings[:12])
|
||||
return f"""
|
||||
<div
|
||||
class="review-panel"
|
||||
data-html5-review
|
||||
hx-get="/html5/projects/{quote(project_id)}/review"
|
||||
{live_attr}
|
||||
hx-swap="outerHTML"
|
||||
{oob_attr}
|
||||
>
|
||||
<div class="panel-title">{escape(title)} · {len(findings)}</div>
|
||||
{_review_summary(findings)}
|
||||
<div class="review-list">{body}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def render_html5_object_context(
|
||||
project_id: str,
|
||||
schema: object | None,
|
||||
impact: object | None,
|
||||
access: object | None = None,
|
||||
ui: object | None = None,
|
||||
runtime: Iterable[object] | None = None,
|
||||
knowledge: Iterable[object] | None = None,
|
||||
privacy: object | None = None,
|
||||
integrations: Iterable[object] | None = None,
|
||||
flowchart: object | None = None,
|
||||
mode: str = "overview",
|
||||
) -> str:
|
||||
if schema is None or impact is None:
|
||||
return f"""
|
||||
<div class="object-context" data-html5-object-context>
|
||||
<div class="panel-title">Object context</div>
|
||||
<p class="muted padded">Выберите объект метаданных, чтобы сервер собрал schema/impact контекст проекта {escape(project_id)}.</p>
|
||||
</div>
|
||||
"""
|
||||
obj = getattr(schema, "object", None) or getattr(impact, "object", None)
|
||||
name = getattr(obj, "qualified_name", None) or getattr(obj, "name", None) or "object"
|
||||
attributes = getattr(schema, "attributes", []) or []
|
||||
sections = getattr(schema, "tabular_sections", []) or []
|
||||
modules = getattr(impact, "modules", []) or []
|
||||
routines = getattr(impact, "routines", []) or []
|
||||
forms = getattr(impact, "forms", []) or []
|
||||
commands = getattr(impact, "commands", []) or []
|
||||
roles = getattr(impact, "roles", []) or []
|
||||
jobs = getattr(impact, "jobs", []) or []
|
||||
callees = getattr(impact, "callees", []) or []
|
||||
query_tables = getattr(impact, "query_tables", []) or []
|
||||
writes = getattr(impact, "writes", []) or []
|
||||
grants = getattr(access, "grants", []) if access is not None else []
|
||||
ui_forms = getattr(ui, "forms", []) if ui is not None else []
|
||||
runtime_items = list(runtime or [])
|
||||
knowledge_items = list(knowledge or [])
|
||||
privacy_markers = getattr(privacy, "markers", []) if privacy is not None else []
|
||||
integration_items = list(integrations or [])
|
||||
flow_nodes = getattr(flowchart, "nodes", []) if flowchart is not None else []
|
||||
flow_edges = getattr(flowchart, "edges", []) if flowchart is not None else []
|
||||
normalized_mode = mode if mode in {"overview", "schema", "impact", "privacy"} else "overview"
|
||||
if normalized_mode == "schema":
|
||||
compact_body = (
|
||||
''.join(_named_node_item("attr", item) for item in attributes[:12])
|
||||
or '<p class="muted padded">Реквизиты не найдены</p>'
|
||||
)
|
||||
compact_body += ''.join(_tabular_section_item(item) for item in sections[:8])
|
||||
compact_body += ''.join(_ui_form_item(item) for item in ui_forms[:8])
|
||||
elif normalized_mode == "impact":
|
||||
compact_body = ''.join(_integration_endpoint_item(item) for item in integration_items[:8])
|
||||
compact_body += ''.join(_named_node_item("command", item) for item in commands[:8])
|
||||
compact_body += ''.join(_named_node_item("read", item) for item in query_tables[:8])
|
||||
compact_body += ''.join(_named_node_item("write", item) for item in writes[:8])
|
||||
compact_body += ''.join(_named_node_item("call", item) for item in callees[:8])
|
||||
compact_body += ''.join(_flowchart_edge_item(project_id, item, flow_nodes, 1) for item in flow_edges[:8])
|
||||
compact_body += ''.join(_named_node_item("routine", item) for item in routines[:8])
|
||||
compact_body += ''.join(_named_node_item("job", item) for item in jobs[:6])
|
||||
compact_body = compact_body or '<p class="muted padded">Impact-связи не найдены</p>'
|
||||
elif normalized_mode == "privacy":
|
||||
compact_body = ''.join(_role_access_item(item) for item in grants[:12])
|
||||
compact_body += ''.join(_privacy_marker_item(item) for item in privacy_markers[:12])
|
||||
compact_body = compact_body or '<p class="muted padded">Доступы и privacy-маркеры не найдены</p>'
|
||||
else:
|
||||
compact_body = (
|
||||
''.join(_named_node_item("attr", item) for item in attributes[:6])
|
||||
or '<p class="muted padded">Реквизиты не найдены</p>'
|
||||
)
|
||||
compact_body += ''.join(_tabular_section_item(item) for item in sections[:4])
|
||||
compact_body += ''.join(_ui_form_item(item) for item in ui_forms[:4])
|
||||
compact_body += ''.join(_role_access_item(item) for item in grants[:6])
|
||||
compact_body += ''.join(_integration_endpoint_item(item) for item in integration_items[:4])
|
||||
compact_body += ''.join(_named_node_item("command", item) for item in commands[:6])
|
||||
compact_body += ''.join(_named_node_item("read", item) for item in query_tables[:4])
|
||||
compact_body += ''.join(_named_node_item("write", item) for item in writes[:4])
|
||||
compact_body += ''.join(_named_node_item("call", item) for item in callees[:6])
|
||||
compact_body += ''.join(_flowchart_edge_item(project_id, item, flow_nodes, 1) for item in flow_edges[:8])
|
||||
compact_body += ''.join(_runtime_summary_item(item) for item in runtime_items[:6])
|
||||
compact_body += ''.join(_knowledge_record_item(item) for item in knowledge_items[:6])
|
||||
compact_body += ''.join(_privacy_marker_item(item) for item in privacy_markers[:6])
|
||||
compact_body += ''.join(_named_node_item("routine", item) for item in routines[:6])
|
||||
compact_body += ''.join(_named_node_item("job", item) for item in jobs[:4])
|
||||
return f"""
|
||||
<div class="object-context" data-html5-object-context data-html5-object-name="{escape(str(name))}" data-html5-object-mode="{escape(normalized_mode)}">
|
||||
<div class="panel-title">Object context · {escape(normalized_mode)}</div>
|
||||
{_object_breadcrumb(str(name))}
|
||||
<article class="object-focus">
|
||||
<strong>{escape(str(name))}</strong>
|
||||
<span>{escape(str(getattr(obj, "kind", "object")))}</span>
|
||||
</article>
|
||||
{_object_action_links(project_id, str(name), getattr(obj, "lineage_id", ""), modules, normalized_mode)}
|
||||
{_object_summary(
|
||||
len(attributes),
|
||||
len(sections),
|
||||
len(commands),
|
||||
len(query_tables),
|
||||
len(writes),
|
||||
len(callees),
|
||||
len(integration_items),
|
||||
len(grants) or len(roles),
|
||||
len(runtime_items),
|
||||
len(privacy_markers),
|
||||
)}
|
||||
<dl class="report-grid">
|
||||
{_metric("Attrs", len(attributes))}
|
||||
{_metric("Tables", len(sections))}
|
||||
{_metric("Modules", len(modules))}
|
||||
{_metric("Routines", len(routines))}
|
||||
{_metric("Forms", len(ui_forms) or len(forms))}
|
||||
{_metric("Commands", len(commands))}
|
||||
{_metric("Roles", len(grants) or len(roles))}
|
||||
{_metric("Reads", len(query_tables))}
|
||||
{_metric("Writes", len(writes))}
|
||||
{_metric("Calls", len(callees))}
|
||||
{_metric("Integrations", len(integration_items))}
|
||||
{_metric("Graph nodes", len(flow_nodes))}
|
||||
{_metric("Graph edges", len(flow_edges))}
|
||||
{_metric("Runtime", len(runtime_items))}
|
||||
{_metric("Knowledge", len(knowledge_items))}
|
||||
{_metric("Privacy", len(privacy_markers))}
|
||||
</dl>
|
||||
<div class="compact-list">
|
||||
{compact_body}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def _review_summary(findings: list[dict]) -> str:
|
||||
severities = Counter(str(item.get("severity") or item.get("level") or "INFO") for item in findings)
|
||||
titles = Counter(str(item.get("title") or item.get("code") or "Finding") for item in findings)
|
||||
severity_text = ", ".join(f"{name}: {count}" for name, count in sorted(severities.items())) or "no severities"
|
||||
title_text = ", ".join(f"{name}: {count}" for name, count in sorted(titles.items())[:4]) or "no findings"
|
||||
return f"""
|
||||
<p class="review-summary" data-html5-review-summary>
|
||||
{escape(str(len(findings)))} findings · {escape(severity_text)} · {escape(title_text)}
|
||||
</p>
|
||||
"""
|
||||
|
||||
|
||||
def _review_item(project_id: str, finding: dict) -> str:
|
||||
title = str(finding.get("title") or finding.get("code") or "Finding")
|
||||
severity = str(finding.get("severity") or finding.get("level") or "INFO")
|
||||
message = str(finding.get("message") or finding.get("description") or "")
|
||||
source_path = str(finding.get("source_path") or finding.get("path") or "")
|
||||
line = finding.get("line_start") or finding.get("line")
|
||||
location = f"{source_path}:{line}" if source_path and line else source_path
|
||||
source_link = (
|
||||
f"""
|
||||
<a
|
||||
href="/html5/projects/{quote(project_id)}/source/by-path?path={quote(source_path, safe='')}"
|
||||
hx-get="/html5/projects/{quote(project_id)}/source/by-path?path={quote(source_path, safe='')}"
|
||||
hx-target="[data-html5-source]"
|
||||
hx-swap="outerHTML"
|
||||
data-html5-review-source="{escape(source_path)}"
|
||||
>Source</a>
|
||||
"""
|
||||
if source_path
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
<article class="review-item" data-html5-review-finding="{escape(severity)}">
|
||||
<strong>{escape(title)}</strong>
|
||||
<span>{escape(severity)}</span>
|
||||
<small>{escape(message or location or "no details")}</small>
|
||||
<span class="inline-actions">{source_link}</span>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _named_node_item(label: str, node: object) -> str:
|
||||
name = getattr(node, "qualified_name", None) or getattr(node, "name", "")
|
||||
kind = getattr(node, "kind", label)
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="{escape(label)}">
|
||||
<strong>{escape(str(name))}</strong>
|
||||
<small>{escape(str(kind))}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _object_breadcrumb(object_name: str) -> str:
|
||||
parts = [part for part in object_name.split(".") if part]
|
||||
if not parts:
|
||||
return ""
|
||||
items = "".join(f"<span>{escape(part)}</span>" for part in parts)
|
||||
return f'<nav class="object-breadcrumb" data-html5-object-breadcrumb>{items}</nav>'
|
||||
|
||||
|
||||
def _object_summary(
|
||||
attributes: int,
|
||||
sections: int,
|
||||
commands: int,
|
||||
reads: int,
|
||||
writes: int,
|
||||
calls: int,
|
||||
integrations: int,
|
||||
access_rules: int,
|
||||
runtime_signals: int,
|
||||
privacy_markers: int,
|
||||
) -> str:
|
||||
impact_total = reads + writes + calls
|
||||
status_bits = [
|
||||
f"{attributes} attrs",
|
||||
f"{sections} tables",
|
||||
f"{commands} commands",
|
||||
f"{impact_total} impact links",
|
||||
f"{access_rules} access rules",
|
||||
]
|
||||
if integrations:
|
||||
status_bits.append(f"{integrations} integrations")
|
||||
if runtime_signals:
|
||||
status_bits.append(f"{runtime_signals} runtime signals")
|
||||
if privacy_markers:
|
||||
status_bits.append(f"{privacy_markers} privacy markers")
|
||||
return f"""
|
||||
<p class="object-summary" data-html5-object-summary>
|
||||
{escape(" · ".join(status_bits))}
|
||||
</p>
|
||||
"""
|
||||
|
||||
|
||||
def _object_action_links(
|
||||
project_id: str,
|
||||
object_name: str,
|
||||
lineage_id: object,
|
||||
modules: Iterable[object],
|
||||
active_mode: str,
|
||||
) -> str:
|
||||
quoted_project = quote(project_id)
|
||||
quoted_object = quote(object_name, safe="")
|
||||
lineage = str(lineage_id or "")
|
||||
first_module = next(iter(modules), None)
|
||||
module_lineage = str(getattr(first_module, "lineage_id", "") or "")
|
||||
source_link = (
|
||||
f'<a class="button" href="/html5/projects/{quoted_project}/source/{quote(module_lineage, safe="")}" '
|
||||
'hx-get="/html5/projects/{project}/source/{module}" hx-target="[data-html5-source]" hx-swap="outerHTML">Source</a>'.format(
|
||||
project=quoted_project,
|
||||
module=quote(module_lineage, safe=""),
|
||||
)
|
||||
if module_lineage
|
||||
else ""
|
||||
)
|
||||
symbol_link = (
|
||||
f'<a class="button" href="/html5/projects/{quoted_project}/symbols/{quote(lineage, safe="")}/detail" '
|
||||
'hx-get="/html5/projects/{project}/symbols/{lineage}/detail" hx-target="[data-html5-symbol-detail]" hx-swap="outerHTML">Symbol</a>'.format(
|
||||
project=quoted_project,
|
||||
lineage=quote(lineage, safe=""),
|
||||
)
|
||||
if lineage
|
||||
else ""
|
||||
)
|
||||
def active_attrs(mode: str) -> str:
|
||||
return ' aria-current="page" data-html5-object-action-active="true"' if mode == active_mode else ""
|
||||
|
||||
return f"""
|
||||
<nav class="object-actions" data-html5-object-actions>
|
||||
<a
|
||||
class="button"
|
||||
href="/html5/projects/{quoted_project}/objects/context/{quoted_object}"
|
||||
hx-get="/html5/projects/{quoted_project}/objects/context/{quoted_object}"
|
||||
hx-target="[data-html5-object-context]"
|
||||
hx-swap="outerHTML"
|
||||
{active_attrs("overview")}
|
||||
>Overview</a>
|
||||
<a
|
||||
class="button"
|
||||
href="/projects/{quoted_project}/objects/schema/{quoted_object}"
|
||||
hx-get="/html5/projects/{quoted_project}/objects/context/{quoted_object}?mode=schema"
|
||||
hx-target="[data-html5-object-context]"
|
||||
hx-swap="outerHTML"
|
||||
{active_attrs("schema")}
|
||||
>Schema</a>
|
||||
<a
|
||||
class="button"
|
||||
href="/projects/{quoted_project}/objects/impact/{quoted_object}"
|
||||
hx-get="/html5/projects/{quoted_project}/objects/context/{quoted_object}?mode=impact"
|
||||
hx-target="[data-html5-object-context]"
|
||||
hx-swap="outerHTML"
|
||||
{active_attrs("impact")}
|
||||
>Impact</a>
|
||||
<a
|
||||
class="button"
|
||||
href="/projects/{quoted_project}/objects/privacy/{quoted_object}"
|
||||
hx-get="/html5/projects/{quoted_project}/objects/context/{quoted_object}?mode=privacy"
|
||||
hx-target="[data-html5-object-context]"
|
||||
hx-swap="outerHTML"
|
||||
{active_attrs("privacy")}
|
||||
>Privacy</a>
|
||||
<a
|
||||
class="button"
|
||||
href="/html5/projects/{quoted_project}/flowchart?focus={quoted_object}&depth=1"
|
||||
hx-get="/html5/projects/{quoted_project}/flowchart?focus={quoted_object}&depth=1"
|
||||
hx-target="[data-html5-flowchart]"
|
||||
hx-swap="outerHTML"
|
||||
>Flowchart</a>
|
||||
{source_link}
|
||||
{symbol_link}
|
||||
</nav>
|
||||
"""
|
||||
|
||||
|
||||
def _tabular_section_item(section: object) -> str:
|
||||
tabular_section = getattr(section, "tabular_section", None)
|
||||
columns = getattr(section, "columns", []) or []
|
||||
name = getattr(tabular_section, "qualified_name", None) or getattr(tabular_section, "name", "")
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="tabular-section">
|
||||
<strong>{escape(str(name))}</strong>
|
||||
<small>{escape(str(len(columns)))} columns</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _role_access_item(grant: object) -> str:
|
||||
role = getattr(grant, "role", None)
|
||||
permissions = getattr(grant, "permissions", {}) or {}
|
||||
role_name = getattr(role, "qualified_name", None) or getattr(role, "name", "role")
|
||||
enabled = [
|
||||
str(key)
|
||||
for key, value in sorted(permissions.items())
|
||||
if str(value).lower() in {"true", "1", "yes", "да"}
|
||||
]
|
||||
permission_text = ", ".join(enabled) if enabled else "permissions unavailable"
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="role-access">
|
||||
<strong>{escape(str(role_name))}</strong>
|
||||
<small>{escape(permission_text)}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _ui_form_item(form_semantics: object) -> str:
|
||||
form = getattr(form_semantics, "form", None)
|
||||
commands = getattr(form_semantics, "commands", []) or []
|
||||
elements = getattr(form_semantics, "elements", []) or []
|
||||
handlers = getattr(form_semantics, "command_handlers", {}) or {}
|
||||
form_name = getattr(form, "qualified_name", None) or getattr(form, "name", "form")
|
||||
command_names = [
|
||||
str(getattr(command, "name", getattr(command, "qualified_name", "")))
|
||||
for command in commands[:3]
|
||||
]
|
||||
handler_names = [
|
||||
str(getattr(handler, "name", getattr(handler, "qualified_name", "")))
|
||||
for handler in list(handlers.values())[:3]
|
||||
]
|
||||
details = []
|
||||
if command_names:
|
||||
details.append("cmd: " + ", ".join(command_names))
|
||||
if handler_names:
|
||||
details.append("handler: " + ", ".join(handler_names))
|
||||
if elements:
|
||||
details.append(f"{len(elements)} elements")
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="ui-form">
|
||||
<strong>{escape(str(form_name))}</strong>
|
||||
<small>{escape(" · ".join(details) or "UI metadata")}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _runtime_summary_item(item: object) -> str:
|
||||
node = getattr(item, "node", None)
|
||||
name = getattr(node, "qualified_name", None) or getattr(node, "name", "runtime")
|
||||
signal_count = getattr(item, "signal_count", 0)
|
||||
error_count = getattr(item, "error_count", 0)
|
||||
max_duration = getattr(item, "max_duration_ms", None)
|
||||
duration_text = f" · max {max_duration} ms" if max_duration is not None else ""
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="runtime">
|
||||
<strong>{escape(str(name))}</strong>
|
||||
<small>{escape(str(signal_count))} signals · {escape(str(error_count))} errors{escape(duration_text)}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _knowledge_record_item(record: object) -> str:
|
||||
title = str(getattr(record, "title", "knowledge"))
|
||||
scope = _enum_text(getattr(record, "scope", ""))
|
||||
body = str(getattr(record, "body", "") or "")
|
||||
record_id = str(getattr(record, "record_id", ""))
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="knowledge">
|
||||
<strong>{escape(title)}</strong>
|
||||
<small>{escape(scope)} · {escape(record_id)} · {escape(body[:120])}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _privacy_marker_item(marker: object) -> str:
|
||||
classification = _enum_text(getattr(marker, "classification", ""))
|
||||
reason = str(getattr(marker, "reason", "") or "")
|
||||
target_id = str(getattr(marker, "target_id", "") or "target unavailable")
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="privacy">
|
||||
<strong>{escape(classification or "privacy")}</strong>
|
||||
<small>{escape(reason or target_id)}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _integration_endpoint_item(endpoint: object) -> str:
|
||||
name = str(getattr(endpoint, "name", "") or "integration")
|
||||
kind = _enum_text(getattr(endpoint, "kind", "UNKNOWN"))
|
||||
direction = str(getattr(endpoint, "direction", "UNKNOWN") or "UNKNOWN")
|
||||
owner = str(getattr(endpoint, "owner", "") or "owner unavailable")
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="integration">
|
||||
<strong>{escape(name)}</strong>
|
||||
<small>{escape(kind)} · {escape(direction)} · {escape(owner)}</small>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _object_context_url(project_id: str, name: str) -> str:
|
||||
return f"/html5/projects/{quote(project_id)}/objects/context/{quote(name, safe='')}"
|
||||
|
||||
|
||||
def _flowchart_focus_link(project_id: str, name: str, depth: int) -> str:
|
||||
url = _flowchart_url(project_id, name, depth)
|
||||
return f"""
|
||||
<a
|
||||
href="{url}"
|
||||
hx-get="{url}"
|
||||
hx-target="[data-html5-flowchart]"
|
||||
hx-swap="outerHTML"
|
||||
data-html5-flowchart-focus="{escape(name)}"
|
||||
>{escape(name)}</a>
|
||||
"""
|
||||
|
||||
|
||||
def _flowchart_context_link(project_id: str, name: str, kind: str) -> str:
|
||||
if kind not in _HTML5_OBJECT_CONTEXT_KINDS:
|
||||
return ""
|
||||
url = _object_context_url(project_id, name)
|
||||
return f"""
|
||||
<a
|
||||
href="{url}"
|
||||
hx-get="{url}"
|
||||
hx-target="[data-html5-object-context]"
|
||||
hx-swap="outerHTML"
|
||||
data-html5-flowchart-context="{escape(name)}"
|
||||
>Context</a>
|
||||
"""
|
||||
|
||||
|
||||
def _flowchart_edge_item(project_id: str, edge: object, nodes: Iterable[object], depth: int) -> str:
|
||||
node_names = {
|
||||
str(getattr(node, "id", "")): str(getattr(node, "qualified_name", "") or getattr(node, "label", ""))
|
||||
for node in nodes
|
||||
}
|
||||
node_kinds = {
|
||||
str(getattr(node, "id", "")): str(getattr(node, "kind", "") or "NODE")
|
||||
for node in nodes
|
||||
}
|
||||
source = node_names.get(str(getattr(edge, "source", "")), str(getattr(edge, "source", "")))
|
||||
target = node_names.get(str(getattr(edge, "target", "")), str(getattr(edge, "target", "")))
|
||||
source_kind = node_kinds.get(str(getattr(edge, "source", "")), "")
|
||||
target_kind = node_kinds.get(str(getattr(edge, "target", "")), "")
|
||||
label = str(getattr(edge, "label", "") or getattr(edge, "kind", "") or "link")
|
||||
kind = str(getattr(edge, "kind", "") or "FLOW")
|
||||
return f"""
|
||||
<article class="object-context-item" data-html5-object-context-item="flow-edge">
|
||||
<strong>{escape(label)}</strong>
|
||||
<small>{_flowchart_focus_link(project_id, source, depth)} -> {_flowchart_focus_link(project_id, target, depth)} · {escape(kind)}</small>
|
||||
<span class="inline-actions">
|
||||
{_flowchart_context_link(project_id, source, source_kind)}
|
||||
{_flowchart_context_link(project_id, target, target_kind)}
|
||||
</span>
|
||||
</article>
|
||||
"""
|
||||
|
||||
|
||||
def _flowchart_url(project_id: str, focus: str | None, depth: int) -> str:
|
||||
params = []
|
||||
if focus:
|
||||
params.append(f"focus={quote(focus, safe='')}")
|
||||
params.append(f"depth={depth}")
|
||||
return f"/html5/projects/{quote(project_id)}/flowchart?{'&'.join(params)}"
|
||||
|
||||
|
||||
def _flowchart_depth_actions(project_id: str, focus: str | None, active_depth: int) -> str:
|
||||
buttons = []
|
||||
for depth in [1, 2, 3]:
|
||||
active = ' aria-current="page" data-html5-object-action-active="true"' if depth == active_depth else ""
|
||||
url = _flowchart_url(project_id, focus, depth)
|
||||
buttons.append(
|
||||
f"""
|
||||
<a
|
||||
class="button"
|
||||
href="{url}"
|
||||
hx-get="{url}"
|
||||
hx-target="[data-html5-flowchart]"
|
||||
hx-swap="outerHTML"
|
||||
{active}
|
||||
>Depth {depth}</a>
|
||||
"""
|
||||
)
|
||||
return f'<nav class="object-actions" data-html5-flowchart-actions>{"".join(buttons)}</nav>'
|
||||
|
||||
|
||||
def _flowchart_node_item(project_id: str, node: object, depth: int) -> str:
|
||||
name = str(getattr(node, "qualified_name", "") or getattr(node, "label", "") or "node")
|
||||
kind = str(getattr(node, "kind", "") or "NODE")
|
||||
level = getattr(node, "level", 0)
|
||||
count = getattr(node, "count", 1)
|
||||
url = _flowchart_url(project_id, name, depth)
|
||||
return f"""
|
||||
<article
|
||||
class="object-context-item"
|
||||
data-html5-flowchart-node="{escape(kind)}"
|
||||
data-html5-flowchart-focus="{escape(name)}"
|
||||
hx-get="{url}"
|
||||
hx-target="[data-html5-flowchart]"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<strong>{escape(name)}</strong>
|
||||
<small>{escape(kind)} · level {escape(str(level))} · count {escape(str(count))}</small>
|
||||
<span class="inline-actions">
|
||||
{_flowchart_context_link(project_id, name, kind)}
|
||||
</span>
|
||||
</article>
|
||||
"""
|
||||
@@ -38,11 +38,13 @@ from neo4j import AsyncGraphDatabase
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api_server.html5 import (
|
||||
render_html5_flowchart,
|
||||
render_html5_index,
|
||||
render_html5_project_rows,
|
||||
)
|
||||
from api_server.html5_inspector import (
|
||||
render_html5_flowchart,
|
||||
render_html5_object_context,
|
||||
render_html5_object_report,
|
||||
render_html5_project_rows,
|
||||
render_html5_project_report,
|
||||
render_html5_review,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user