From 0f8141d5f977e0fb96f62b65d42b6beb9ead1557 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 17 May 2026 11:00:50 +0300 Subject: [PATCH] Add HTML5 contracts and operations renderer split --- services/api-server/src/api_server/html5.py | 199 ++---------------- .../src/api_server/html5_operations.py | 185 ++++++++++++++++ services/api-server/src/api_server/main.py | 10 +- services/api-server/tests/test_api.py | 61 ++++++ 4 files changed, 274 insertions(+), 181 deletions(-) create mode 100644 services/api-server/src/api_server/html5_operations.py diff --git a/services/api-server/src/api_server/html5.py b/services/api-server/src/api_server/html5.py index 3c36ece..e0fda12 100644 --- a/services/api-server/src/api_server/html5.py +++ b/services/api-server/src/api_server/html5.py @@ -95,145 +95,6 @@ def render_html5_project_rows(projects: Iterable[object]) -> str: return project_rows -def render_html5_operations( - jobs: Iterable[object], - *, - project_id: str = "", - status: str = "", - kind: str = "", -) -> str: - job_list = list(jobs) - filter_query = _operation_filter_query(project_id=project_id, status=status, kind=kind) - return _page( - "SFERA HTML5 operations", - f""" -
-
-
-

SFERA HTML5

-

Операции сервера

-

Очередь фоновых задач отрисовывается API-сервером и обновляется htmx polling без React runtime.

-
-
- {len(job_list)} - jobs -
-
-
-
-

Очередь

- Проекты -
- {_operation_filter_form(project_id=project_id, status=status, kind=kind)} -
- {render_html5_operation_summary(job_list)} -
-
- - - - - {render_html5_operation_rows(job_list)} -
JobПроектСтатусStageСообщение
-
- {render_html5_operation_detail(None)} -
-
- """, - ) - - -def render_html5_operation_summary(jobs: Iterable[object]) -> str: - job_list = list(jobs) - counts = Counter(_enum_text(getattr(job, "status", "unknown")) for job in job_list) - running = counts.get("RUNNING", 0) - queued = counts.get("QUEUED", 0) - succeeded = counts.get("SUCCEEDED", 0) - failed = counts.get("FAILED", 0) - return f""" -
- {_metric("Всего", len(job_list))} - {_metric("В работе", running)} - {_metric("В очереди", queued)} - {_metric("Успешно", succeeded)} - {_metric("Ошибки", failed)} -
- """ - - -def render_html5_operation_rows(jobs: Iterable[object]) -> str: - rows = "\n".join(_operation_row(job) for job in jobs) - if not rows: - return 'Фоновые операции пока не запускались' - return rows - - -def render_html5_operation_detail(job: object | None) -> str: - if job is None: - return """ -
-
Детали операции
-

Выберите job в таблице, чтобы сервер отрисовал payload, result и логи.

-
- """ - job_id = str(getattr(job, "job_id", "")) - kind = str(getattr(job, "kind", "")) - status = _enum_text(getattr(job, "status", "")) - payload = getattr(job, "payload", {}) or {} - result = getattr(job, "result", {}) or {} - error = str(getattr(job, "error", "") or "") - logs = payload.get("logs") if isinstance(payload.get("logs"), list) else [] - return f""" -
-
Детали операции
-
- {escape(kind)} · {escape(status)} - {escape(job_id)} - {escape(error or "no error")} -
-
- {_metric("Payload keys", len(payload))} - {_metric("Result keys", len(result))} - {_metric("Logs", len(logs))} - {_metric("Updated", str(getattr(job, "updated_at", ""))[:19])} -
-
{escape(_compact_mapping(payload))}
-
{escape(_compact_mapping(result))}
- -
- """ - - -def _operation_filter_form(*, project_id: str, status: str, kind: str) -> str: - return f""" -
- - - - - Сброс -
- """ - - -def _operation_filter_query(*, project_id: str, status: str, kind: str) -> str: - params = [] - if project_id: - params.append(f"project_id={quote(project_id)}") - if status: - params.append(f"status={quote(status)}") - if kind: - params.append(f"kind={quote(kind)}") - return f"?{'&'.join(params)}" if params else "" - - def render_html5_editor( *, project_id: str, @@ -749,6 +610,7 @@ def render_html5_authoring_changes(project_id: str, changes: Iterable[object] | >
Authoring · {len(change_list)}
{_authoring_changes_summary(change_list)} + {_authoring_recent_change(change_list)}
{body}
{render_html5_authoring_change_detail(project_id, None)} @@ -1500,44 +1362,6 @@ def _project_link(project: object, active_project_id: str) -> str: return f'{escape(name)}' -def _operation_row(job: object) -> str: - job_id = str(getattr(job, "job_id", "")) - kind = str(getattr(job, "kind", "")) - status = _enum_text(getattr(job, "status", "")) - payload = getattr(job, "payload", {}) or {} - project_id = str(payload.get("project_id") or "") - stage = str(payload.get("stage") or "") - message = str(payload.get("message") or getattr(job, "error", "") or "") - project_link = ( - f'{escape(project_id)}' - if project_id - else '-' - ) - return f""" - - {escape(kind)}{escape(job_id)} - {project_link} - {escape(status)} - {escape(stage or "-")} - {escape(message or "-")} - - - - """ - - -def _compact_mapping(value: dict) -> str: - if not value: - return "{}" - rows = [f"{key}: {value[key]}" for key in sorted(value)[:20]] - return "\n".join(rows) - - def _topbar(project_id: str, project_nav: str) -> str: return f"""
@@ -1821,6 +1645,27 @@ def _authoring_changes_summary(changes: Iterable[object]) -> str: """ +def _authoring_recent_change(changes: Iterable[object]) -> str: + change_list = list(changes) + if not change_list: + return "" + latest = change_list[0] + change_id = str(getattr(latest, "change_id", "")) + status = str(getattr(latest, "status", "") or "UNKNOWN") + target = getattr(latest, "target", None) + target_name = getattr(target, "qualified_name", None) or getattr(target, "name", None) or "target unavailable" + version = getattr(latest, "version", None) + version_id = str(getattr(latest, "version_id", "") or getattr(version, "version_id", "") or "version unavailable") + approved_by = str(getattr(latest, "approved_by", "") or "not approved") + return f""" +
+ {escape(status)} · {escape(str(target_name))} + {escape(version_id)} + {escape(approved_by)} · {escape(change_id)} +
+ """ + + def _authoring_result_summary(state: str, diff: Iterable[object], checks: Iterable[object]) -> str: diff_list = list(diff) check_list = list(checks) diff --git a/services/api-server/src/api_server/html5_operations.py b/services/api-server/src/api_server/html5_operations.py new file mode 100644 index 0000000..a32224c --- /dev/null +++ b/services/api-server/src/api_server/html5_operations.py @@ -0,0 +1,185 @@ +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, _page + + +def render_html5_operations( + jobs: Iterable[object], + *, + project_id: str = "", + status: str = "", + kind: str = "", +) -> str: + job_list = list(jobs) + filter_query = _operation_filter_query(project_id=project_id, status=status, kind=kind) + return _page( + "SFERA HTML5 operations", + f""" +
+
+
+

SFERA HTML5

+

Операции сервера

+

Очередь фоновых задач отрисовывается API-сервером и обновляется SSE без React runtime.

+
+
+ {len(job_list)} + jobs +
+
+
+
+

Очередь

+ Проекты +
+ {_operation_filter_form(project_id=project_id, status=status, kind=kind)} +
+ {render_html5_operation_summary(job_list)} +
+
+ + + + + {render_html5_operation_rows(job_list)} +
JobПроектСтатусStageСообщение
+
+ {render_html5_operation_detail(None)} +
+
+ """, + ) + + +def render_html5_operation_summary(jobs: Iterable[object]) -> str: + job_list = list(jobs) + counts = Counter(_enum_text(getattr(job, "status", "unknown")) for job in job_list) + running = counts.get("RUNNING", 0) + queued = counts.get("QUEUED", 0) + succeeded = counts.get("SUCCEEDED", 0) + failed = counts.get("FAILED", 0) + return f""" +
+ {_metric("Всего", len(job_list))} + {_metric("В работе", running)} + {_metric("В очереди", queued)} + {_metric("Успешно", succeeded)} + {_metric("Ошибки", failed)} +
+ """ + + +def render_html5_operation_rows(jobs: Iterable[object]) -> str: + rows = "\n".join(_operation_row(job) for job in jobs) + if not rows: + return 'Фоновые операции пока не запускались' + return rows + + +def render_html5_operation_detail(job: object | None) -> str: + if job is None: + return """ +
+
Детали операции
+

Выберите job в таблице, чтобы сервер отрисовал payload, result и логи.

+
+ """ + job_id = str(getattr(job, "job_id", "")) + kind = str(getattr(job, "kind", "")) + status = _enum_text(getattr(job, "status", "")) + payload = getattr(job, "payload", {}) or {} + result = getattr(job, "result", {}) or {} + error = str(getattr(job, "error", "") or "") + logs = payload.get("logs") if isinstance(payload.get("logs"), list) else [] + return f""" +
+
Детали операции
+
+ {escape(kind)} · {escape(status)} + {escape(job_id)} + {escape(error or "no error")} +
+
+ {_metric("Payload keys", len(payload))} + {_metric("Result keys", len(result))} + {_metric("Logs", len(logs))} + {_metric("Updated", str(getattr(job, "updated_at", ""))[:19])} +
+
{escape(_compact_mapping(payload))}
+
{escape(_compact_mapping(result))}
+
    {"".join(f"
  • {escape(str(item))}
  • " for item in logs[-8:]) or "
  • Лог пока пустой
  • "}
+
+ """ + + +def _operation_row(job: object) -> str: + job_id = str(getattr(job, "job_id", "")) + kind = str(getattr(job, "kind", "")) + status = _enum_text(getattr(job, "status", "")) + payload = getattr(job, "payload", {}) or {} + project_id = str(payload.get("project_id") or "") + stage = str(payload.get("stage") or "") + message = str(payload.get("message") or getattr(job, "error", "") or "") + project_link = ( + f'{escape(project_id)}' + if project_id + else '-' + ) + return f""" + + {escape(kind)}{escape(job_id)} + {project_link} + {escape(status)} + {escape(stage or "-")} + {escape(message or "-")} + + + + """ + + +def _compact_mapping(value: dict) -> str: + if not value: + return "{}" + rows = [f"{key}: {value[key]}" for key in sorted(value)[:20]] + return "\n".join(rows) + + +def _operation_filter_form(*, project_id: str, status: str, kind: str) -> str: + return f""" +
+ + + + + Сброс +
+ """ + + +def _operation_filter_query(*, project_id: str, status: str, kind: str) -> str: + params = [] + if project_id: + params.append(f"project_id={quote(project_id)}") + if status: + params.append(f"status={quote(status)}") + if kind: + params.append(f"kind={quote(kind)}") + return f"?{'&'.join(params)}" if params else "" diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index c95260b..e05b574 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -51,7 +51,6 @@ from api_server.html5 import ( render_html5_metadata_preview_result, render_html5_object_context, render_html5_object_report, - render_html5_operation_detail, render_html5_project_setup, render_html5_project_rows, render_html5_project_report, @@ -59,15 +58,18 @@ from api_server.html5 import ( render_html5_symbol_detail, render_html5_import_check, render_html5_import_job, - render_html5_operation_rows, - render_html5_operation_summary, - render_html5_operations, render_html5_settings_panel, render_html5_setup_summary, render_html5_source, render_html5_status, render_html5_symbols, ) +from api_server.html5_operations import ( + render_html5_operation_detail, + render_html5_operation_rows, + render_html5_operation_summary, + render_html5_operations, +) from impact_engine import object_impact, routine_impact from incremental_indexer import rebuild_changed_file from integration_topology import IntegrationKind, build_integration_topology diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index ea669be..a43608c 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -28,6 +28,20 @@ def create_authoring_session(client: TestClient, project_id: str, task_id: str, assert session.status_code == 200 +def assert_html5_contract(text: str, *markers: str, full_page: bool = False) -> None: + assert "__next" not in text + assert "unpkg.com" not in text + assert 'hx-trigger="every' not in text + if full_page: + assert "" in text + assert "/html5/assets/htmx.min.js" in text + assert "/html5/assets/htmx-ext-sse.js" in text + else: + assert "