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)}
-
-
-
-
- | Job | Проект | Статус | Stage | Сообщение | |
-
- {render_html5_operation_rows(job_list)}
-
-
- {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_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)}
+
+
+
+
+ | Job | Проект | Статус | Stage | Сообщение | |
+
+ {render_html5_operation_rows(job_list)}
+
+
+ {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 "