Add HTML5 operations monitor
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-05-16 21:53:56 +03:00
parent c3193b8211
commit 84303f208b
3 changed files with 122 additions and 0 deletions
@@ -70,6 +70,54 @@ def render_html5_project_rows(projects: Iterable[object]) -> str:
return project_rows return project_rows
def render_html5_operations(jobs: Iterable[object]) -> str:
job_list = list(jobs)
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">
<div class="section-title">
<h2>Очередь</h2>
<a class="button" href="/html5">Проекты</a>
</div>
<div class="table-wrap">
<table data-html5-operations>
<thead>
<tr><th>Job</th><th>Проект</th><th>Статус</th><th>Stage</th><th>Сообщение</th></tr>
</thead>
<tbody
data-html5-operations-body
hx-get="/html5/operations/jobs"
hx-trigger="every 3s"
hx-swap="innerHTML"
>{render_html5_operation_rows(job_list)}</tbody>
</table>
</div>
</section>
</main>
""",
)
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="5" class="muted">Фоновые операции пока не запускались</td></tr>'
return rows
def render_html5_editor( def render_html5_editor(
*, *,
project_id: str, project_id: str,
@@ -474,6 +522,29 @@ 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>' 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>
</tr>"""
def _topbar(project_id: str, project_nav: str) -> str: def _topbar(project_id: str, project_nav: str) -> str:
return f""" return f"""
<header class="topbar" data-html5-topbar> <header class="topbar" data-html5-topbar>
@@ -42,6 +42,8 @@ from api_server.html5 import (
render_html5_project_rows, render_html5_project_rows,
render_html5_import_check, render_html5_import_check,
render_html5_import_job, render_html5_import_job,
render_html5_operation_rows,
render_html5_operations,
render_html5_settings_panel, render_html5_settings_panel,
render_html5_setup_summary, render_html5_setup_summary,
render_html5_source, render_html5_source,
@@ -1612,6 +1614,22 @@ async def html5_delete_project(project_id: str, request: Request) -> Response:
) )
@app.get("/html5/operations")
async def html5_operations() -> Response:
return Response(
render_html5_operations(_html5_operation_jobs()),
media_type="text/html; charset=utf-8",
)
@app.get("/html5/operations/jobs")
async def html5_operation_jobs() -> Response:
return Response(
render_html5_operation_rows(_html5_operation_jobs()),
media_type="text/html; charset=utf-8",
)
@app.get("/html5/projects/{project_id}/editor") @app.get("/html5/projects/{project_id}/editor")
async def html5_project_editor(project_id: str, q: str = "") -> Response: async def html5_project_editor(project_id: str, q: str = "") -> Response:
try: try:
@@ -7837,6 +7855,10 @@ def _current_import_source(project_id: str) -> ImportSourceKind:
return ImportSourceKind.XML_DUMP return ImportSourceKind.XML_DUMP
def _html5_operation_jobs() -> list[OperationJob]:
return sorted(_operations.jobs.values(), key=lambda job: job.updated_at, reverse=True)[:50]
def _project_summaries() -> list[ProjectSummaryResponse]: def _project_summaries() -> list[ProjectSummaryResponse]:
project_ids = set(_project_setup.keys()) project_ids = set(_project_setup.keys())
stored_snapshots = _storage.list_snapshot_refs() stored_snapshots = _storage.list_snapshot_refs()
+29
View File
@@ -287,6 +287,35 @@ def test_html5_project_setup_renders_server_fragments():
assert "<html" not in summary.text assert "<html" not in summary.text
def test_html5_operations_renders_job_monitor_fragments():
client = TestClient(app)
project_id = f"html5-ops-{uuid4()}"
saved = client.post(
f"/projects/{project_id}/settings",
json={"name": "HTML5 Ops", "structure_source": "XML_DUMP"},
)
assert saved.status_code == 200
job = client.post(f"/html5/projects/{project_id}/setup/import-job")
assert job.status_code == 200
page = client.get("/html5/operations")
assert page.status_code == 200
assert "text/html" in page.headers["content-type"]
assert 'data-html5-page="operations"' in page.text
assert "data-html5-operations-body" in page.text
assert 'hx-get="/html5/operations/jobs"' in page.text
assert project_id in page.text
assert "__next" not in page.text
rows = client.get("/html5/operations/jobs")
assert rows.status_code == 200
assert "text/html" in rows.headers["content-type"]
assert "data-html5-operation" in rows.text
assert project_id in rows.text
assert "<html" not in rows.text
def test_project_setup_mock_import_indexes_project(): def test_project_setup_mock_import_indexes_project():
client = TestClient(app) client = TestClient(app)
project_id = f"setup-import-{uuid4()}" project_id = f"setup-import-{uuid4()}"