From b93fd88e810febd7b76d0c7c3b89292dec7ca578 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 17 May 2026 00:11:26 +0300 Subject: [PATCH] Add HTML5 object runtime context --- services/api-server/src/api_server/html5.py | 19 ++++++++++++ services/api-server/src/api_server/main.py | 32 ++++++++++++++++++++- services/api-server/tests/test_api.py | 17 +++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/services/api-server/src/api_server/html5.py b/services/api-server/src/api_server/html5.py index 812e992..9f9f3e1 100644 --- a/services/api-server/src/api_server/html5.py +++ b/services/api-server/src/api_server/html5.py @@ -322,6 +322,7 @@ def render_html5_object_context( impact: object | None, access: object | None = None, ui: object | None = None, + runtime: Iterable[object] | None = None, ) -> str: if schema is None or impact is None: return f""" @@ -341,6 +342,7 @@ def render_html5_object_context( jobs = getattr(impact, "jobs", []) 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 []) return f"""
Object context
@@ -355,12 +357,14 @@ def render_html5_object_context( {_metric("Routines", len(routines))} {_metric("Forms", len(ui_forms) or len(forms))} {_metric("Roles", len(grants) or len(roles))} + {_metric("Runtime", len(runtime_items))}
{''.join(_named_node_item("attr", item) for item in attributes[:6]) or '

Реквизиты не найдены

'} {''.join(_tabular_section_item(item) for item in sections[:4])} {''.join(_ui_form_item(item) for item in ui_forms[:4])} {''.join(_role_access_item(item) for item in grants[:6])} + {''.join(_runtime_summary_item(item) for item in runtime_items[:6])} {''.join(_named_node_item("routine", item) for item in routines[:6])} {''.join(_named_node_item("job", item) for item in jobs[:4])}
@@ -1423,6 +1427,21 @@ def _ui_form_item(form_semantics: object) -> str: """ +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""" +
+ {escape(str(name))} + {escape(str(signal_count))} signals · {escape(str(error_count))} errors{escape(duration_text)} +
+ """ + + def _authoring_diff_item(line: object) -> str: kind = str(getattr(line, "kind", "")) text = str(getattr(line, "text", "")) diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 3884e83..d1abfd7 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -1747,8 +1747,9 @@ async def html5_project_object_context(project_id: str, object_name: str) -> Res impact = await get_object_impact(project_id, object_name) access = await get_object_access(project_id, object_name) ui = await get_object_ui(project_id, object_name) + runtime = _runtime_for_object_context(project_id, impact) return Response( - render_html5_object_context(project_id, schema, impact, access, ui), + render_html5_object_context(project_id, schema, impact, access, ui, runtime), media_type="text/html; charset=utf-8", ) @@ -8112,6 +8113,35 @@ def _html5_metadata_tabular_sections(raw: str) -> list[dict]: return sections +def _runtime_for_object_context(project_id: str, impact: ObjectImpactResponse) -> list[RuntimeSummaryResponse]: + lineages = { + item.lineage_id + for group in [ + [impact.object], + impact.modules, + impact.routines, + impact.forms, + impact.commands, + impact.jobs, + impact.writes, + ] + for item in group + if item is not None + } + snapshot = _project_snapshot_or_404(project_id) + overlay = _overlays.get(project_id, RuntimeOverlay(project_id=project_id)) + return [ + RuntimeSummaryResponse( + node=_named_node(item.node), + signal_count=item.signal_count, + error_count=item.error_count, + max_duration_ms=item.max_duration_ms, + ) + for item in summarize_runtime(snapshot, overlay) + if item.node.lineage_id in lineages + ] + + def _current_import_source(project_id: str) -> ImportSourceKind: setup = _project_setup_response(project_id) if setup.current_source is not None: diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index 41ac53b..db4c130 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -254,6 +254,20 @@ def test_html5_object_context_fragment(tmp_path: Path): client = TestClient(app) indexed = client.post("/projects/index", json={"path": str(tmp_path), "project_id": project_id}) assert indexed.status_code == 200 + snapshot = client.get(f"/projects/{project_id}/snapshot/export").json() + handler = next(node for node in snapshot["nodes"] if node["name"] == "ПровестиКоманда") + signal = client.post( + f"/projects/{project_id}/runtime/signals", + json={ + "signal": { + "signal_id": "html5-runtime.1", + "lineage_id": handler["lineage_id"], + "kind": "ERROR", + "duration_ms": 125.0, + } + }, + ) + assert signal.status_code == 200 editor = client.get(f"/html5/projects/{project_id}/editor") assert editor.status_code == 200 @@ -271,6 +285,9 @@ def test_html5_object_context_fragment(tmp_path: Path): assert "ФормаДокумента" in context.text assert "Провести" in context.text assert "ПровестиКоманда" in context.text + assert "1 signals" in context.text + assert "1 errors" in context.text + assert "125.0 ms" in context.text assert "Роль.Менеджер" in context.text assert "read, write, post" in context.text or "post, read, write" in context.text assert "