Add HTML5 contracts and operations renderer split
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-05-17 11:00:50 +03:00
parent 22f59b7580
commit 0f8141d5f9
4 changed files with 274 additions and 181 deletions
+22 -177
View File
@@ -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"""
<main class="shell" data-html5-page="operations">
<section class="hero">
<div>
<p class="eyebrow">SFERA HTML5</p>
<h1>Операции сервера</h1>
<p class="lead">Очередь фоновых задач отрисовывается API-сервером и обновляется htmx polling без React runtime.</p>
</div>
<div class="hero-metrics">
<strong>{len(job_list)}</strong>
<span>jobs</span>
</div>
</section>
<section class="band" hx-ext="sse" sse-connect="/html5/operations/events{filter_query}">
<div class="section-title">
<h2>Очередь</h2>
<a class="button" href="/html5">Проекты</a>
</div>
{_operation_filter_form(project_id=project_id, status=status, kind=kind)}
<div data-html5-operations-summary-stream sse-swap="operations-summary" hx-swap="innerHTML">
{render_html5_operation_summary(job_list)}
</div>
<div class="table-wrap">
<table data-html5-operations>
<thead>
<tr><th>Job</th><th>Проект</th><th>Статус</th><th>Stage</th><th>Сообщение</th><th></th></tr>
</thead>
<tbody
data-html5-operations-body
sse-swap="operations-jobs"
hx-swap="innerHTML"
>{render_html5_operation_rows(job_list)}</tbody>
</table>
</div>
{render_html5_operation_detail(None)}
</section>
</main>
""",
)
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"""
<div
class="ops-summary"
data-html5-operations-summary
>
{_metric("Всего", len(job_list))}
{_metric("В работе", running)}
{_metric("В очереди", queued)}
{_metric("Успешно", succeeded)}
{_metric("Ошибки", failed)}
</div>
"""
def render_html5_operation_rows(jobs: Iterable[object]) -> str:
rows = "\n".join(_operation_row(job) for job in jobs)
if not rows:
return '<tr><td colspan="6" class="muted">Фоновые операции пока не запускались</td></tr>'
return rows
def render_html5_operation_detail(job: object | None) -> str:
if job is None:
return """
<div class="operation-detail" data-html5-operation-detail>
<div class="panel-title flush">Детали операции</div>
<p class="muted padded">Выберите job в таблице, чтобы сервер отрисовал payload, result и логи.</p>
</div>
"""
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"""
<div class="operation-detail" data-html5-operation-detail data-html5-operation-detail-id="{escape(job_id)}">
<div class="panel-title flush">Детали операции</div>
<article class="setup-detail">
<strong>{escape(kind)} · {escape(status)}</strong>
<span>{escape(job_id)}</span>
<small>{escape(error or "no error")}</small>
</article>
<div class="report-grid">
{_metric("Payload keys", len(payload))}
{_metric("Result keys", len(result))}
{_metric("Logs", len(logs))}
{_metric("Updated", str(getattr(job, "updated_at", ""))[:19])}
</div>
<pre class="code">{escape(_compact_mapping(payload))}</pre>
<pre class="code">{escape(_compact_mapping(result))}</pre>
<ul class="job-log">{"".join(f"<li>{escape(str(item))}</li>" for item in logs[-8:]) or "<li>Лог пока пустой</li>"}</ul>
</div>
"""
def _operation_filter_form(*, project_id: str, status: str, kind: str) -> str:
return f"""
<form class="ops-filter" data-html5-operations-filter method="get" action="/html5/operations">
<input name="project_id" value="{escape(project_id)}" placeholder="project_id" />
<input name="status" value="{escape(status)}" placeholder="status" />
<input name="kind" value="{escape(kind)}" placeholder="kind" />
<button type="submit">Фильтр</button>
<a class="button" href="/html5/operations">Сброс</a>
</form>
"""
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] |
>
<div class="panel-title">Authoring · {len(change_list)}</div>
{_authoring_changes_summary(change_list)}
{_authoring_recent_change(change_list)}
<div class="review-list">{body}</div>
{render_html5_authoring_change_detail(project_id, None)}
</div>
@@ -1500,44 +1362,6 @@ def _project_link(project: object, active_project_id: str) -> str:
return f'<a class="project-link{active}" href="/html5/projects/{quote(project_id)}/editor">{escape(name)}</a>'
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'<a href="/html5/projects/{quote(project_id)}/setup">{escape(project_id)}</a>'
if project_id
else '<span class="muted">-</span>'
)
return f"""
<tr data-html5-operation="{escape(job_id)}">
<td><strong>{escape(kind)}</strong><small>{escape(job_id)}</small></td>
<td>{project_link}</td>
<td>{escape(status)}</td>
<td>{escape(stage or "-")}</td>
<td>{escape(message or "-")}</td>
<td>
<button
type="button"
hx-get="/html5/operations/jobs/{quote(job_id)}/detail"
hx-target="[data-html5-operation-detail]"
hx-swap="outerHTML"
>Открыть</button>
</td>
</tr>"""
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"""
<header class="topbar" data-html5-topbar>
@@ -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"""
<article class="authoring-change" data-html5-authoring-recent-change="{escape(change_id)}">
<strong>{escape(status)} · {escape(str(target_name))}</strong>
<span>{escape(version_id)}</span>
<small>{escape(approved_by)} · {escape(change_id)}</small>
</article>
"""
def _authoring_result_summary(state: str, diff: Iterable[object], checks: Iterable[object]) -> str:
diff_list = list(diff)
check_list = list(checks)
@@ -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"""
<main class="shell" data-html5-page="operations">
<section class="hero">
<div>
<p class="eyebrow">SFERA HTML5</p>
<h1>Операции сервера</h1>
<p class="lead">Очередь фоновых задач отрисовывается API-сервером и обновляется SSE без React runtime.</p>
</div>
<div class="hero-metrics">
<strong>{len(job_list)}</strong>
<span>jobs</span>
</div>
</section>
<section class="band" hx-ext="sse" sse-connect="/html5/operations/events{filter_query}">
<div class="section-title">
<h2>Очередь</h2>
<a class="button" href="/html5">Проекты</a>
</div>
{_operation_filter_form(project_id=project_id, status=status, kind=kind)}
<div data-html5-operations-summary-stream sse-swap="operations-summary" hx-swap="innerHTML">
{render_html5_operation_summary(job_list)}
</div>
<div class="table-wrap">
<table data-html5-operations>
<thead>
<tr><th>Job</th><th>Проект</th><th>Статус</th><th>Stage</th><th>Сообщение</th><th></th></tr>
</thead>
<tbody
data-html5-operations-body
sse-swap="operations-jobs"
hx-swap="innerHTML"
>{render_html5_operation_rows(job_list)}</tbody>
</table>
</div>
{render_html5_operation_detail(None)}
</section>
</main>
""",
)
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"""
<div
class="ops-summary"
data-html5-operations-summary
>
{_metric("Всего", len(job_list))}
{_metric("В работе", running)}
{_metric("В очереди", queued)}
{_metric("Успешно", succeeded)}
{_metric("Ошибки", failed)}
</div>
"""
def render_html5_operation_rows(jobs: Iterable[object]) -> str:
rows = "\n".join(_operation_row(job) for job in jobs)
if not rows:
return '<tr><td colspan="6" class="muted">Фоновые операции пока не запускались</td></tr>'
return rows
def render_html5_operation_detail(job: object | None) -> str:
if job is None:
return """
<div class="operation-detail" data-html5-operation-detail>
<div class="panel-title flush">Детали операции</div>
<p class="muted padded">Выберите job в таблице, чтобы сервер отрисовал payload, result и логи.</p>
</div>
"""
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"""
<div class="operation-detail" data-html5-operation-detail data-html5-operation-detail-id="{escape(job_id)}">
<div class="panel-title flush">Детали операции</div>
<article class="setup-detail">
<strong>{escape(kind)} · {escape(status)}</strong>
<span>{escape(job_id)}</span>
<small>{escape(error or "no error")}</small>
</article>
<div class="report-grid">
{_metric("Payload keys", len(payload))}
{_metric("Result keys", len(result))}
{_metric("Logs", len(logs))}
{_metric("Updated", str(getattr(job, "updated_at", ""))[:19])}
</div>
<pre class="code">{escape(_compact_mapping(payload))}</pre>
<pre class="code">{escape(_compact_mapping(result))}</pre>
<ul class="job-log">{"".join(f"<li>{escape(str(item))}</li>" for item in logs[-8:]) or "<li>Лог пока пустой</li>"}</ul>
</div>
"""
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'<a href="/html5/projects/{quote(project_id)}/setup">{escape(project_id)}</a>'
if project_id
else '<span class="muted">-</span>'
)
return f"""
<tr data-html5-operation="{escape(job_id)}">
<td><strong>{escape(kind)}</strong><small>{escape(job_id)}</small></td>
<td>{project_link}</td>
<td>{escape(status)}</td>
<td>{escape(stage or "-")}</td>
<td>{escape(message or "-")}</td>
<td>
<button
type="button"
hx-get="/html5/operations/jobs/{quote(job_id)}/detail"
hx-target="[data-html5-operation-detail]"
hx-swap="outerHTML"
>Открыть</button>
</td>
</tr>"""
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"""
<form class="ops-filter" data-html5-operations-filter method="get" action="/html5/operations">
<input name="project_id" value="{escape(project_id)}" placeholder="project_id" />
<input name="status" value="{escape(status)}" placeholder="status" />
<input name="kind" value="{escape(kind)}" placeholder="kind" />
<button type="submit">Фильтр</button>
<a class="button" href="/html5/operations">Сброс</a>
</form>
"""
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 ""
+6 -4
View File
@@ -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