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 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) job_list = list(jobs)
filter_query = _operation_filter_query(project_id=project_id, status=status, kind=kind)
return _page( return _page(
"SFERA HTML5 operations", "SFERA HTML5 operations",
f""" f"""
@@ -112,18 +119,19 @@ def render_html5_operations(jobs: Iterable[object]) -> str:
<span>jobs</span> <span>jobs</span>
</div> </div>
</section> </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"> <div class="section-title">
<h2>Очередь</h2> <h2>Очередь</h2>
<a class="button" href="/html5">Проекты</a> <a class="button" href="/html5">Проекты</a>
</div> </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"> <div data-html5-operations-summary-stream sse-swap="operations-summary" hx-swap="innerHTML">
{render_html5_operation_summary(job_list)} {render_html5_operation_summary(job_list)}
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table data-html5-operations> <table data-html5-operations>
<thead> <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> </thead>
<tbody <tbody
data-html5-operations-body data-html5-operations-body
@@ -132,6 +140,7 @@ def render_html5_operations(jobs: Iterable[object]) -> str:
>{render_html5_operation_rows(job_list)}</tbody> >{render_html5_operation_rows(job_list)}</tbody>
</table> </table>
</div> </div>
{render_html5_operation_detail(None)}
</section> </section>
</main> </main>
""", """,
@@ -162,10 +171,69 @@ def render_html5_operation_summary(jobs: Iterable[object]) -> str:
def render_html5_operation_rows(jobs: Iterable[object]) -> str: def render_html5_operation_rows(jobs: Iterable[object]) -> str:
rows = "\n".join(_operation_row(job) for job in jobs) rows = "\n".join(_operation_row(job) for job in jobs)
if not rows: if not rows:
return '<tr><td colspan="5" class="muted">Фоновые операции пока не запускались</td></tr>' return '<tr><td colspan="6" class="muted">Фоновые операции пока не запускались</td></tr>'
return rows 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( def render_html5_editor(
*, *,
project_id: str, project_id: str,
@@ -1390,8 +1458,8 @@ def _page(title: str, body: str) -> str:
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{escape(title)}</title> <title>{escape(title)}</title>
<style>{_css()}</style> <style>{_css()}</style>
<script defer src="https://unpkg.com/htmx.org@2.0.4"></script> <script defer src="/html5/assets/htmx.min.js"></script>
<script defer src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script> <script defer src="/html5/assets/htmx-ext-sse.js"></script>
</head> </head>
<body>{body}</body> <body>{body}</body>
</html>""" </html>"""
@@ -1452,9 +1520,24 @@ def _operation_row(job: object) -> str:
<td>{escape(status)}</td> <td>{escape(status)}</td>
<td>{escape(stage or "-")}</td> <td>{escape(stage or "-")}</td>
<td>{escape(message 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>""" </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: 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>
@@ -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)} .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} .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} .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){.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}} @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 import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import PlainTextResponse, Response, StreamingResponse from fastapi.responses import PlainTextResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from neo4j import AsyncGraphDatabase from neo4j import AsyncGraphDatabase
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -50,6 +51,7 @@ from api_server.html5 import (
render_html5_metadata_preview_result, render_html5_metadata_preview_result,
render_html5_object_context, render_html5_object_context,
render_html5_object_report, render_html5_object_report,
render_html5_operation_detail,
render_html5_project_setup, render_html5_project_setup,
render_html5_project_rows, render_html5_project_rows,
render_html5_project_report, render_html5_project_report,
@@ -125,6 +127,8 @@ from transaction_topology import routines_touching_target, transaction_write_set
from ui_semantics import form_semantics from ui_semantics import form_semantics
app = FastAPI(title="SFERA API", version="0.1.0") 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origin_regex=os.environ.get( 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") @app.get("/html5/operations")
async def html5_operations() -> Response: async def html5_operations(project_id: str = "", status: str = "", kind: str = "") -> Response:
return 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", media_type="text/html; charset=utf-8",
) )
@app.get("/html5/operations/jobs") @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( 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", media_type="text/html; charset=utf-8",
) )
@app.get("/html5/operations/summary") @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( 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", media_type="text/html; charset=utf-8",
) )
@app.get("/html5/operations/events") @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(): def stream_operations():
last_fragments: dict[str, str] = {}
while True: while True:
jobs = _html5_operation_jobs() yield _html5_sse_comment("operations heartbeat")
yield _html5_sse_event("operations-summary", render_html5_operation_summary(jobs)) jobs = _html5_operation_jobs(project_id=project_id, status=status, kind=kind)
yield _html5_sse_event("operations-jobs", render_html5_operation_rows(jobs)) 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: if once:
break break
time.sleep(3) 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") @app.get("/html5/projects/{project_id}/events")
async def html5_project_events(project_id: str, once: bool = False) -> StreamingResponse: async def html5_project_events(project_id: str, once: bool = False) -> StreamingResponse:
async def stream_status(): async def stream_status():
last_fragments: dict[str, str] = {}
while True: while True:
yield _html5_sse_comment(f"project {project_id} heartbeat")
try: try:
snapshot = _project_snapshot_or_404(project_id) snapshot = _project_snapshot_or_404(project_id)
fragment = render_html5_status(project_id, snapshot) 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 report = None
findings = None findings = None
flowchart = None flowchart = None
yield _html5_sse_event("status", fragment) for event_text in _html5_sse_if_changed(last_fragments, "status", fragment):
yield _html5_sse_event( yield event_text
for event_text in _html5_sse_if_changed(
last_fragments,
"authoring-changes", "authoring-changes",
render_html5_authoring_changes(project_id, _authoring_change_summaries(project_id)), render_html5_authoring_changes(project_id, _authoring_change_summaries(project_id)),
) ):
yield event_text
if report is not None: 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: 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: 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: if once:
break break
await asyncio.sleep(5) 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") @app.get("/html5/projects/{project_id}/setup/events")
async def html5_project_setup_events(project_id: str, once: bool = False) -> StreamingResponse: async def html5_project_setup_events(project_id: str, once: bool = False) -> StreamingResponse:
async def stream_setup(): async def stream_setup():
last_fragments: dict[str, str] = {}
while True: while True:
setup = _project_setup_response(project_id) yield _html5_sse_comment(f"setup {project_id} heartbeat")
yield _html5_sse_event("setup-summary", render_html5_setup_summary(project_id, setup)) try:
yield _html5_sse_event("setup-import-job", render_html5_import_job(project_id, _html5_latest_import_job(project_id))) 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: if once:
break break
await asyncio.sleep(2) await asyncio.sleep(2)
@@ -8406,8 +8458,21 @@ def _current_import_source(project_id: str) -> ImportSourceKind:
return ImportSourceKind.XML_DUMP return ImportSourceKind.XML_DUMP
def _html5_operation_jobs() -> list[OperationJob]: def _html5_operation_jobs(project_id: str = "", status: str = "", kind: str = "") -> list[OperationJob]:
return sorted(_operations.jobs.values(), key=lambda job: job.updated_at, reverse=True)[:50] 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: 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: def _html5_sse_event(event: str, fragment: str) -> str:
data = "\n".join(f"data: {line}" for line in fragment.splitlines()) 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]: 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-swap="outerHTML"' in editor.text
assert "hx-ext=\"sse\"" in editor.text assert "hx-ext=\"sse\"" in editor.text
assert f"sse-connect=\"/html5/projects/{project_id}/events\"" in editor.text assert f"sse-connect=\"/html5/projects/{project_id}/events\"" in editor.text
assert "htmx.org" in editor.text assert '/html5/assets/htmx.min.js' in editor.text
assert "htmx-ext-sse" 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 "client-js: htmx+sse only" in editor.text
assert ".object-actions .button[data-html5-object-action-active" in editor.text assert ".object-actions .button[data-html5-object-action-active" in editor.text
assert ".inline-actions" 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 assert "__next" not in editor.text
with client.stream("GET", f"/html5/projects/{project_id}/events?once=1") as events: 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: status" in first_chunk
assert "event: authoring-changes" in first_chunk assert "event: authoring-changes" in first_chunk
assert "event: project-report" 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 "Изменений пока нет" in authoring.text
assert "<html" not 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(): def test_html5_project_index_creates_project_with_fragment():
client = TestClient(app) client = TestClient(app)
@@ -625,7 +637,9 @@ def test_html5_project_setup_renders_server_fragments():
assert "<html" not in summary.text assert "<html" not in summary.text
with client.stream("GET", f"/html5/projects/{project_id}/setup/events?once=1") as events: 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-summary" in first_chunk
assert "event: setup-import-job" in first_chunk assert "event: setup-import-job" in first_chunk
assert "data-html5-setup-summary" 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-page="operations"' in page.text
assert "data-html5-operations-body" in page.text assert "data-html5-operations-body" in page.text
assert "data-html5-operations-summary" 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 'hx-ext="sse"' in page.text
assert 'sse-connect="/html5/operations/events"' in page.text assert 'sse-connect="/html5/operations/events"' in page.text
assert 'sse-swap="operations-summary"' 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 assert "__next" not in page.text
with client.stream("GET", "/html5/operations/events?once=1") as events: 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-summary" in first_chunk
assert "event: operations-jobs" in first_chunk assert "event: operations-jobs" in first_chunk
assert "data-html5-operations-summary" 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 rows.status_code == 200
assert "text/html" in rows.headers["content-type"] assert "text/html" in rows.headers["content-type"]
assert "data-html5-operation" in rows.text assert "data-html5-operation" in rows.text
assert 'hx-target="[data-html5-operation-detail]"' in rows.text
assert project_id in rows.text assert project_id in rows.text
assert "<html" not 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") summary = client.get("/html5/operations/summary")
assert summary.status_code == 200 assert summary.status_code == 200
assert "text/html" in summary.headers["content-type"] 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 "Всего" in summary.text
assert "<html" not 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(): def test_project_setup_mock_import_indexes_project():
client = TestClient(app) client = TestClient(app)