Harden HTML5 SSE and local assets
This commit is contained in:
@@ -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}}
|
||||
"""
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user