Harden HTML5 SSE and local assets
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-05-17 10:49:48 +03:00
parent 65c82c4fed
commit 22f59b7580
5 changed files with 535 additions and 34 deletions
+90 -7
View File
@@ -95,8 +95,15 @@ def render_html5_project_rows(projects: Iterable[object]) -> str:
return project_rows
def render_html5_operations(jobs: Iterable[object]) -> str:
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"""
@@ -112,18 +119,19 @@ def render_html5_operations(jobs: Iterable[object]) -> str:
<span>jobs</span>
</div>
</section>
<section class="band" hx-ext="sse" sse-connect="/html5/operations/events">
<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></tr>
<tr><th>Job</th><th>Проект</th><th>Статус</th><th>Stage</th><th>Сообщение</th><th></th></tr>
</thead>
<tbody
data-html5-operations-body
@@ -132,6 +140,7 @@ def render_html5_operations(jobs: Iterable[object]) -> str:
>{render_html5_operation_rows(job_list)}</tbody>
</table>
</div>
{render_html5_operation_detail(None)}
</section>
</main>
""",
@@ -162,10 +171,69 @@ def render_html5_operation_summary(jobs: Iterable[object]) -> str:
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 '<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,
@@ -1390,8 +1458,8 @@ def _page(title: str, body: str) -> str:
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{escape(title)}</title>
<style>{_css()}</style>
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script>
<script defer src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
<script defer src="/html5/assets/htmx.min.js"></script>
<script defer src="/html5/assets/htmx-ext-sse.js"></script>
</head>
<body>{body}</body>
</html>"""
@@ -1452,9 +1520,24 @@ def _operation_row(job: object) -> str:
<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>
@@ -2262,7 +2345,7 @@ def _css() -> str:
.layout{display:grid;grid-template-columns:280px minmax(0,1fr)320px;height:calc(100vh - 88px)}.panel{overflow:auto}.panel-title{padding:12px;border-bottom:1px solid var(--line);font-size:12px;font-weight:900;text-transform:uppercase;color:var(--muted)}.tree-item{display:block;padding:10px 12px;border-bottom:1px solid var(--line);text-decoration:none}.tree-item span{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tree-item small{color:var(--muted)}
.editor{min-width:0;overflow:hidden;border-top:0;border-bottom:0}.editor-head{height:72px;border-bottom:1px solid var(--line);padding:0 14px}.editor-head h1{margin:0;font-size:18px}.search{display:flex;gap:6px}.search input{height:32px;width:260px;border:1px solid var(--line);padding:0 10px}.source-panel{height:calc(100% - 72px);display:grid;grid-template-rows:auto auto minmax(0,1fr);overflow:hidden}.source-head{display:flex;justify-content:space-between;gap:12px;align-items:center;min-height:54px;padding:10px 14px;border-bottom:1px solid var(--line);background:#fff}.source-head strong,.source-head small{display:block}.source-head small{color:var(--muted)}.source-head dl{display:flex;gap:12px;margin:0}.source-head div div{padding:0}.source-head dt{font-size:11px;color:var(--muted)}.source-head dd{margin:0;font-weight:800}.source-summary{margin:0;padding:8px 14px;border-bottom:1px solid var(--line);background:#fffdf8;color:var(--muted);font-size:12px;font-weight:800}.code{height:100%;margin:0;overflow:auto;padding:16px;background:#fbfaf7;font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}
.metrics{display:grid;grid-template-columns:1fr 1fr;margin:0}.metrics div{padding:12px;border-bottom:1px solid var(--line)}.metrics dt{color:var(--muted);font-size:12px}.metrics dd{margin:2px 0 0;font-size:22px;font-weight:800}.compact{list-style:none;margin:0;padding:0}.compact li{display:flex;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--line)}.object-actions{display:flex;gap:6px;flex-wrap:wrap;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.object-actions .button{height:28px;padding:0 9px;font-size:12px}.object-actions .button[data-html5-object-action-active="true"],.object-actions .button[aria-current="page"]{background:var(--brand);border-color:var(--brand);color:#fff}.object-breadcrumb{display:flex;gap:6px;flex-wrap:wrap;padding:9px 12px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;font-weight:800;color:var(--muted)}.object-breadcrumb span:not(:last-child)::after{content:"/";margin-left:6px;color:#98a2b3}.object-breadcrumb span:last-child{color:var(--ink)}.object-summary,.symbol-summary,.review-summary,.project-summary,.object-report-summary,.authoring-summary{margin:0;padding:10px 12px;border-bottom:1px solid var(--line);background:#f8fbff;color:var(--muted);font-size:12px;font-weight:800;line-height:1.45}.symbol{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line);cursor:pointer}.symbol:hover{background:#f8fbff}.symbol span,.symbol small{color:var(--muted)}.symbol-focus,.symbol-reference,.object-focus,.object-context-item{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.symbol-focus small,.symbol-reference span,.symbol-reference small,.object-focus span,.object-context-item small{color:var(--muted)}.inline-actions{display:flex;gap:8px;flex-wrap:wrap;font-size:12px;font-weight:800}.inline-actions a{color:var(--brand);text-decoration:none}.report-grid{display:grid;grid-template-columns:1fr 1fr;margin:0}.report-grid div{padding:10px 12px;border-bottom:1px solid var(--line)}.report-grid dt{color:var(--muted);font-size:12px}.report-grid dd{margin:2px 0 0;font-size:20px;font-weight:900}.review-item,.authoring-change{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.authoring-change[hx-get]{cursor:pointer}.authoring-change[hx-get]:hover{background:#f8fbff}.review-item span,.review-item small,.authoring-change span,.authoring-change small{color:var(--muted)}.diff-item{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px;padding:8px 12px;border-bottom:1px solid var(--line)}.diff-item span{color:var(--muted);font-weight:800}.diff-item code{white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}.status{position:fixed;bottom:0;left:0;right:0;display:flex;gap:18px;overflow:auto;height:34px;align-items:center;border-top:1px solid var(--line);background:#fff;padding:0 12px;color:var(--muted);font-size:12px}.empty-state{max-width:720px;margin:80px auto;background:#fff;border:1px solid var(--line);padding:28px}
.setup-layout{display:grid;grid-template-columns:340px minmax(0,1fr);gap:16px;max-width:1180px;margin:18px auto;padding:0 16px}.setup-workspace{background:#f3f6fb}.setup-card{padding:16px;border-bottom:1px solid var(--line)}.setup-card h1{margin:0;font-size:24px}.setup-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.setup-main{min-height:620px}.settings-form{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:8px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff}.settings-form label{display:grid;gap:4px;font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase}.settings-form input{height:32px;border:1px solid var(--line);padding:0 8px}.saved{float:right;color:var(--ok);font-weight:900}.setup-actions-panel{display:flex;gap:8px;flex-wrap:wrap;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.inline-form{display:flex;gap:8px;align-items:end}.inline-form label{display:grid;gap:4px;font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase}.inline-form select{height:32px;min-width:240px;border:1px solid var(--line);background:#fff;padding:0 8px}.rollback-form,.authoring-preview-form{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.rollback-form input,.authoring-preview-form input,.authoring-preview-form textarea{min-width:0;border:1px solid var(--line);padding:0 8px}.rollback-form input,.authoring-preview-form input{height:32px}.authoring-preview-form textarea{grid-column:1/-1;min-height:88px;padding:8px;resize:vertical;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}.rollback-form button,.authoring-preview-form button{grid-column:1/-1}.setup-summary{display:grid;gap:0}.compact-lead{margin:0;padding:0 16px 16px}.setup-metrics,.ops-summary{display:grid;grid-template-columns:repeat(4,1fr);margin:0;border-top:1px solid var(--line)}.ops-summary{grid-template-columns:repeat(5,1fr);margin:12px 0}.setup-metrics div,.ops-summary div{padding:16px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);background:#fff}.setup-metrics div:last-child,.ops-summary div:last-child{border-right:0}.setup-metrics dt,.ops-summary dt{font-size:12px;color:var(--muted)}.setup-metrics dd,.ops-summary dd{margin:4px 0 0;font-size:28px;font-weight:900}.status-pill{border:1px solid var(--line);padding:6px 10px;background:#eef6f1;color:var(--ok);font-weight:800}.flush{border-top:1px solid var(--line)}.padded{padding:12px 16px;margin:0}.setup-detail,.history-item,.source-card,.check-item{display:grid;gap:3px;padding:12px 16px;border-bottom:1px solid var(--line)}.setup-detail span,.setup-detail small,.history-item span,.history-item small,.source-card span,.source-card small,.check-item span,.check-item small,.check-head span,.check-head small{color:var(--muted)}.source-list,.history-list,.check-list{display:grid}.check-head{display:flex;gap:10px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff}.job-log{margin:0;padding:0;list-style:none}.job-log li{padding:8px 16px;border-bottom:1px solid var(--line);color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
.setup-layout{display:grid;grid-template-columns:340px minmax(0,1fr);gap:16px;max-width:1180px;margin:18px auto;padding:0 16px}.setup-workspace{background:#f3f6fb}.setup-card{padding:16px;border-bottom:1px solid var(--line)}.setup-card h1{margin:0;font-size:24px}.setup-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.setup-main{min-height:620px}.settings-form{display:grid;grid-template-columns:2fr 1fr 1fr auto;gap:8px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff}.settings-form label{display:grid;gap:4px;font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase}.settings-form input{height:32px;border:1px solid var(--line);padding:0 8px}.saved{float:right;color:var(--ok);font-weight:900}.setup-actions-panel,.ops-filter{display:flex;gap:8px;flex-wrap:wrap;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.ops-filter input{height:32px;min-width:180px;border:1px solid var(--line);padding:0 8px}.inline-form{display:flex;gap:8px;align-items:end}.inline-form label{display:grid;gap:4px;font-size:12px;font-weight:800;color:var(--muted);text-transform:uppercase}.inline-form select{height:32px;min-width:240px;border:1px solid var(--line);background:#fff;padding:0 8px}.rollback-form,.authoring-preview-form{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.rollback-form input,.authoring-preview-form input,.authoring-preview-form textarea{min-width:0;border:1px solid var(--line);padding:0 8px}.rollback-form input,.authoring-preview-form input{height:32px}.authoring-preview-form textarea{grid-column:1/-1;min-height:88px;padding:8px;resize:vertical;font:12px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace}.rollback-form button,.authoring-preview-form button{grid-column:1/-1}.setup-summary{display:grid;gap:0}.compact-lead{margin:0;padding:0 16px 16px}.setup-metrics,.ops-summary{display:grid;grid-template-columns:repeat(4,1fr);margin:0;border-top:1px solid var(--line)}.ops-summary{grid-template-columns:repeat(5,1fr);margin:12px 0}.setup-metrics div,.ops-summary div{padding:16px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);background:#fff}.setup-metrics div:last-child,.ops-summary div:last-child{border-right:0}.setup-metrics dt,.ops-summary dt{font-size:12px;color:var(--muted)}.setup-metrics dd,.ops-summary dd{margin:4px 0 0;font-size:28px;font-weight:900}.status-pill{border:1px solid var(--line);padding:6px 10px;background:#eef6f1;color:var(--ok);font-weight:800}.flush{border-top:1px solid var(--line)}.padded{padding:12px 16px;margin:0}.setup-detail,.history-item,.source-card,.check-item{display:grid;gap:3px;padding:12px 16px;border-bottom:1px solid var(--line)}.setup-detail span,.setup-detail small,.history-item span,.history-item small,.source-card span,.source-card small,.check-item span,.check-item small,.check-head span,.check-head small{color:var(--muted)}.source-list,.history-list,.check-list{display:grid}.check-head{display:flex;gap:10px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--line);background:#fff}.job-log{margin:0;padding:0;list-style:none}.job-log li{padding:8px 16px;border-bottom:1px solid var(--line);color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
@media(max-width:980px){.layout{grid-template-columns:1fr;height:auto}.tree,.inspector{max-height:320px}.editor{min-height:520px}.hero{display:block}.shell{padding:16px}}
@media(max-width:980px){.setup-layout{grid-template-columns:1fr}.setup-metrics{grid-template-columns:1fr 1fr}.settings-form{grid-template-columns:1fr}}
"""
+98 -22
View File
@@ -33,6 +33,7 @@ from collaboration import (
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import PlainTextResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from neo4j import AsyncGraphDatabase
from pydantic import BaseModel, Field
@@ -50,6 +51,7 @@ 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,
@@ -125,6 +127,8 @@ from transaction_topology import routines_touching_target, transaction_write_set
from ui_semantics import form_semantics
app = FastAPI(title="SFERA API", version="0.1.0")
_HTML5_ASSETS_DIR = Path(__file__).resolve().parent / "static" / "html5"
app.mount("/html5/assets", StaticFiles(directory=_HTML5_ASSETS_DIR), name="html5-assets")
app.add_middleware(
CORSMiddleware,
allow_origin_regex=os.environ.get(
@@ -1631,36 +1635,59 @@ async def html5_delete_project(project_id: str, request: Request) -> Response:
@app.get("/html5/operations")
async def html5_operations() -> Response:
async def html5_operations(project_id: str = "", status: str = "", kind: str = "") -> Response:
return Response(
render_html5_operations(_html5_operation_jobs()),
render_html5_operations(
_html5_operation_jobs(project_id=project_id, status=status, kind=kind),
project_id=project_id,
status=status,
kind=kind,
),
media_type="text/html; charset=utf-8",
)
@app.get("/html5/operations/jobs")
async def html5_operation_jobs() -> Response:
async def html5_operation_jobs(project_id: str = "", status: str = "", kind: str = "") -> Response:
return Response(
render_html5_operation_rows(_html5_operation_jobs()),
render_html5_operation_rows(_html5_operation_jobs(project_id=project_id, status=status, kind=kind)),
media_type="text/html; charset=utf-8",
)
@app.get("/html5/operations/jobs/{job_id}/detail")
async def html5_operation_job_detail(job_id: str) -> Response:
job = _operations.jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Unknown operation job: {job_id}")
return Response(
render_html5_operation_detail(job),
media_type="text/html; charset=utf-8",
)
@app.get("/html5/operations/summary")
async def html5_operation_summary() -> Response:
async def html5_operation_summary(project_id: str = "", status: str = "", kind: str = "") -> Response:
return Response(
render_html5_operation_summary(_html5_operation_jobs()),
render_html5_operation_summary(_html5_operation_jobs(project_id=project_id, status=status, kind=kind)),
media_type="text/html; charset=utf-8",
)
@app.get("/html5/operations/events")
async def html5_operations_events(once: bool = False) -> StreamingResponse:
async def html5_operations_events(
once: bool = False,
project_id: str = "",
status: str = "",
kind: str = "",
) -> StreamingResponse:
def stream_operations():
last_fragments: dict[str, str] = {}
while True:
jobs = _html5_operation_jobs()
yield _html5_sse_event("operations-summary", render_html5_operation_summary(jobs))
yield _html5_sse_event("operations-jobs", render_html5_operation_rows(jobs))
yield _html5_sse_comment("operations heartbeat")
jobs = _html5_operation_jobs(project_id=project_id, status=status, kind=kind)
yield from _html5_sse_if_changed(last_fragments, "operations-summary", render_html5_operation_summary(jobs))
yield from _html5_sse_if_changed(last_fragments, "operations-jobs", render_html5_operation_rows(jobs))
if once:
break
time.sleep(3)
@@ -1696,7 +1723,9 @@ async def html5_project_editor(project_id: str, q: str = "") -> Response:
@app.get("/html5/projects/{project_id}/events")
async def html5_project_events(project_id: str, once: bool = False) -> StreamingResponse:
async def stream_status():
last_fragments: dict[str, str] = {}
while True:
yield _html5_sse_comment(f"project {project_id} heartbeat")
try:
snapshot = _project_snapshot_or_404(project_id)
fragment = render_html5_status(project_id, snapshot)
@@ -1708,17 +1737,23 @@ async def html5_project_events(project_id: str, once: bool = False) -> Streaming
report = None
findings = None
flowchart = None
yield _html5_sse_event("status", fragment)
yield _html5_sse_event(
for event_text in _html5_sse_if_changed(last_fragments, "status", fragment):
yield event_text
for event_text in _html5_sse_if_changed(
last_fragments,
"authoring-changes",
render_html5_authoring_changes(project_id, _authoring_change_summaries(project_id)),
)
):
yield event_text
if report is not None:
yield _html5_sse_event("project-report", render_html5_project_report(project_id, report))
for event_text in _html5_sse_if_changed(last_fragments, "project-report", render_html5_project_report(project_id, report)):
yield event_text
if findings is not None:
yield _html5_sse_event("project-review", render_html5_review(project_id, findings))
for event_text in _html5_sse_if_changed(last_fragments, "project-review", render_html5_review(project_id, findings)):
yield event_text
if flowchart is not None:
yield _html5_sse_event("project-flowchart", render_html5_flowchart(project_id, flowchart))
for event_text in _html5_sse_if_changed(last_fragments, "project-flowchart", render_html5_flowchart(project_id, flowchart)):
yield event_text
if once:
break
await asyncio.sleep(5)
@@ -2024,10 +2059,27 @@ async def html5_project_setup_summary(project_id: str) -> Response:
@app.get("/html5/projects/{project_id}/setup/events")
async def html5_project_setup_events(project_id: str, once: bool = False) -> StreamingResponse:
async def stream_setup():
last_fragments: dict[str, str] = {}
while True:
setup = _project_setup_response(project_id)
yield _html5_sse_event("setup-summary", render_html5_setup_summary(project_id, setup))
yield _html5_sse_event("setup-import-job", render_html5_import_job(project_id, _html5_latest_import_job(project_id)))
yield _html5_sse_comment(f"setup {project_id} heartbeat")
try:
setup = _project_setup_response(project_id)
except HTTPException as error:
setup_error = f'<div class="setup-summary" data-html5-setup-summary><p class="muted padded">{error.detail}</p></div>'
for event_text in _html5_sse_if_changed(last_fragments, "setup-summary", setup_error):
yield event_text
if once:
break
await asyncio.sleep(2)
continue
for event_text in _html5_sse_if_changed(last_fragments, "setup-summary", render_html5_setup_summary(project_id, setup)):
yield event_text
for event_text in _html5_sse_if_changed(
last_fragments,
"setup-import-job",
render_html5_import_job(project_id, _html5_latest_import_job(project_id)),
):
yield event_text
if once:
break
await asyncio.sleep(2)
@@ -8406,8 +8458,21 @@ def _current_import_source(project_id: str) -> ImportSourceKind:
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 _html5_operation_jobs(project_id: str = "", status: str = "", kind: str = "") -> list[OperationJob]:
normalized_project = project_id.strip().casefold()
normalized_status = status.strip().casefold()
normalized_kind = kind.strip().casefold()
jobs = []
for job in _operations.jobs.values():
payload = job.payload or {}
if normalized_project and str(payload.get("project_id") or "").casefold() != normalized_project:
continue
if normalized_status and _operation_value(getattr(job, "status", "")).casefold() != normalized_status:
continue
if normalized_kind and _operation_value(getattr(job, "kind", "")).casefold() != normalized_kind:
continue
jobs.append(job)
return sorted(jobs, key=lambda job: job.updated_at, reverse=True)[:50]
def _html5_latest_import_job(project_id: str) -> OperationJob | None:
@@ -8425,7 +8490,18 @@ def _operation_value(value: object) -> str:
def _html5_sse_event(event: str, fragment: str) -> str:
data = "\n".join(f"data: {line}" for line in fragment.splitlines())
return f"event: {event}\n{data}\n\n"
return f"event: {event}\nretry: 5000\n{data}\n\n"
def _html5_sse_if_changed(last_fragments: dict[str, str], event: str, fragment: str):
if last_fragments.get(event) == fragment:
return
last_fragments[event] = fragment
yield _html5_sse_event(event, fragment)
def _html5_sse_comment(message: str) -> str:
return f": {message}\n\n"
def _project_summaries() -> list[ProjectSummaryResponse]:
@@ -0,0 +1,290 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('sse', {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},
getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}
return
// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})
/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}
// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}
var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(elt, sseURL, retryCount)
}
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}
api.getInternalData(elt).sseEventSource = source
var closeAttribute = api.getAttributeValue(elt, "sse-close");
if (closeAttribute) {
// close eventsource when this message is received
source.addEventListener(closeAttribute, function() {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'message',
})
source.close()
});
}
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})
var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec)
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()
File diff suppressed because one or more lines are too long
+56 -5
View File
@@ -136,8 +136,9 @@ def test_html5_server_rendered_project_editor(tmp_path: Path):
assert 'hx-swap="outerHTML"' in editor.text
assert "hx-ext=\"sse\"" in editor.text
assert f"sse-connect=\"/html5/projects/{project_id}/events\"" in editor.text
assert "htmx.org" in editor.text
assert "htmx-ext-sse" in editor.text
assert '/html5/assets/htmx.min.js' in editor.text
assert '/html5/assets/htmx-ext-sse.js' in editor.text
assert "unpkg.com" not in editor.text
assert "client-js: htmx+sse only" in editor.text
assert ".object-actions .button[data-html5-object-action-active" in editor.text
assert ".inline-actions" in editor.text
@@ -147,7 +148,9 @@ def test_html5_server_rendered_project_editor(tmp_path: Path):
assert "__next" not in editor.text
with client.stream("GET", f"/html5/projects/{project_id}/events?once=1") as events:
first_chunk = next(events.iter_text())
first_chunk = "".join(events.iter_text())
assert ": project " in first_chunk
assert "retry: 5000" in first_chunk
assert "event: status" in first_chunk
assert "event: authoring-changes" in first_chunk
assert "event: project-report" in first_chunk
@@ -232,6 +235,15 @@ def test_html5_server_rendered_project_editor(tmp_path: Path):
assert "Изменений пока нет" in authoring.text
assert "<html" not in authoring.text
htmx_asset = client.get("/html5/assets/htmx.min.js")
assert htmx_asset.status_code == 200
assert "javascript" in htmx_asset.headers["content-type"]
assert "htmx" in htmx_asset.text
sse_asset = client.get("/html5/assets/htmx-ext-sse.js")
assert sse_asset.status_code == 200
assert "javascript" in sse_asset.headers["content-type"]
assert "sse" in sse_asset.text
def test_html5_project_index_creates_project_with_fragment():
client = TestClient(app)
@@ -625,7 +637,9 @@ def test_html5_project_setup_renders_server_fragments():
assert "<html" not in summary.text
with client.stream("GET", f"/html5/projects/{project_id}/setup/events?once=1") as events:
first_chunk = next(events.iter_text())
first_chunk = "".join(events.iter_text())
assert ": setup " in first_chunk
assert "retry: 5000" in first_chunk
assert "event: setup-summary" in first_chunk
assert "event: setup-import-job" in first_chunk
assert "data-html5-setup-summary" in first_chunk
@@ -651,6 +665,8 @@ def test_html5_operations_renders_job_monitor_fragments():
assert 'data-html5-page="operations"' in page.text
assert "data-html5-operations-body" in page.text
assert "data-html5-operations-summary" in page.text
assert "data-html5-operations-filter" in page.text
assert "data-html5-operation-detail" in page.text
assert 'hx-ext="sse"' in page.text
assert 'sse-connect="/html5/operations/events"' in page.text
assert 'sse-swap="operations-summary"' in page.text
@@ -660,7 +676,9 @@ def test_html5_operations_renders_job_monitor_fragments():
assert "__next" not in page.text
with client.stream("GET", "/html5/operations/events?once=1") as events:
first_chunk = next(events.iter_text())
first_chunk = "".join(events.iter_text())
assert ": operations heartbeat" in first_chunk
assert "retry: 5000" in first_chunk
assert "event: operations-summary" in first_chunk
assert "event: operations-jobs" in first_chunk
assert "data-html5-operations-summary" in first_chunk
@@ -671,9 +689,20 @@ def test_html5_operations_renders_job_monitor_fragments():
assert rows.status_code == 200
assert "text/html" in rows.headers["content-type"]
assert "data-html5-operation" in rows.text
assert 'hx-target="[data-html5-operation-detail]"' in rows.text
assert project_id in rows.text
assert "<html" not in rows.text
operation_job_id = client.get("/operations/jobs", params={"project_id": project_id, "kind": "SERVER_IMPORT"}).json()[0]["job_id"]
detail = client.get(f"/html5/operations/jobs/{operation_job_id}/detail")
assert detail.status_code == 200
assert "text/html" in detail.headers["content-type"]
assert "data-html5-operation-detail" in detail.text
assert operation_job_id in detail.text
assert "SERVER_IMPORT" in detail.text
assert project_id in detail.text
assert "<html" not in detail.text
summary = client.get("/html5/operations/summary")
assert summary.status_code == 200
assert "text/html" in summary.headers["content-type"]
@@ -681,6 +710,28 @@ def test_html5_operations_renders_job_monitor_fragments():
assert "Всего" in summary.text
assert "<html" not in summary.text
job_status = client.get("/operations/jobs", params={"project_id": project_id, "kind": "SERVER_IMPORT"}).json()[0]["status"]
filtered = client.get("/html5/operations", params={"project_id": project_id, "status": job_status, "kind": "SERVER_IMPORT"})
assert filtered.status_code == 200
assert f'sse-connect="/html5/operations/events?project_id={project_id}&status={job_status}&kind=SERVER_IMPORT"' in filtered.text
assert f'value="{project_id}"' in filtered.text
assert f'value="{job_status}"' in filtered.text
assert 'value="SERVER_IMPORT"' in filtered.text
assert project_id in filtered.text
filtered_rows = client.get("/html5/operations/jobs", params={"project_id": project_id, "status": job_status, "kind": "SERVER_IMPORT"})
assert filtered_rows.status_code == 200
assert project_id in filtered_rows.text
with client.stream(
"GET",
f"/html5/operations/events?once=1&project_id={project_id}&status={job_status}&kind=SERVER_IMPORT",
) as events:
filtered_chunk = "".join(events.iter_text())
assert "event: operations-summary" in filtered_chunk
assert "event: operations-jobs" in filtered_chunk
assert project_id in filtered_chunk
def test_project_setup_mock_import_indexes_project():
client = TestClient(app)