Add server-rendered HTML5 shell with SSE
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-05-16 21:10:39 +03:00
parent 523a756f5c
commit 567b517699
3 changed files with 335 additions and 1 deletions
+243
View File
@@ -0,0 +1,243 @@
from __future__ import annotations
from collections import Counter
from html import escape
from typing import Iterable
from urllib.parse import quote
from sir import NodeKind, SirSnapshot
def render_html5_index(projects: Iterable[object]) -> str:
project_rows = "\n".join(_project_row(project) for project in projects)
if not project_rows:
project_rows = '<tr><td colspan="4" class="muted">Проекты пока не настроены</td></tr>'
return _page(
"SFERA HTML5",
f"""
<main class="shell" data-html5-page="projects">
<section class="hero">
<div>
<p class="eyebrow">SFERA HTML5</p>
<h1>Server-first рабочее место 1С</h1>
<p class="lead">Основной HTML собирает API-сервер. Браузер получает готовую страницу без React/Next runtime.</p>
</div>
<div class="hero-metrics">
<strong>{len(list(projects))}</strong>
<span>проектов</span>
</div>
</section>
<section class="band">
<div class="section-title">
<h2>Проекты</h2>
<a class="button" href="/docs">API docs</a>
</div>
<div class="table-wrap">
<table data-html5-projects>
<thead>
<tr><th>Проект</th><th>Статус</th><th>Snapshot</th><th></th></tr>
</thead>
<tbody>{project_rows}</tbody>
</table>
</div>
</section>
</main>
""",
)
def render_html5_editor(
*,
project_id: str,
projects: Iterable[object],
snapshot: SirSnapshot | None,
error: str | None = None,
q: str = "",
) -> str:
project_nav = "\n".join(_project_link(project, project_id) for project in projects)
if error or snapshot is None:
content = f"""
<main class="workspace" data-html5-page="editor" data-project-id="{escape(project_id)}">
{_topbar(project_id, project_nav)}
<section class="empty-state" data-html5-error>
<h1>Проект не готов к HTML5 IDE</h1>
<p>{escape(error or "Snapshot не найден")}</p>
<a class="button" href="/html5">К списку проектов</a>
</section>
</main>
"""
return _page(f"SFERA HTML5 - {project_id}", content)
counts = Counter(str(node.kind.value if hasattr(node.kind, "value") else node.kind) for node in snapshot.nodes)
modules = [node for node in snapshot.nodes if node.kind == NodeKind.MODULE]
objects = [
node
for node in snapshot.nodes
if node.kind
in {
NodeKind.CATALOG,
NodeKind.DOCUMENT,
NodeKind.REGISTER,
NodeKind.COMMON_MODULE,
NodeKind.REPORT,
NodeKind.DATA_PROCESSOR,
}
]
selected_module = modules[0] if modules else None
query = q.strip().lower()
search_results = [
node for node in snapshot.nodes
if query and query in (node.qualified_name or node.name).lower()
][:30]
if not query:
search_results = modules[:12] or snapshot.nodes[:12]
source_text = _node_source_text(selected_module)
content = f"""
<main class="workspace" data-html5-page="editor" data-project-id="{escape(project_id)}">
{_topbar(project_id, project_nav)}
<section class="layout">
<aside class="panel tree" data-html5-tree>
<div class="panel-title">Дерево объектов</div>
<nav>{''.join(_tree_item(node) for node in objects[:120]) or '<p class="muted">Объекты не найдены</p>'}</nav>
</aside>
<section class="editor" data-html5-editor>
<div class="editor-head">
<div>
<p class="eyebrow">HTML5 editor</p>
<h1>{escape(selected_module.qualified_name if selected_module else project_id)}</h1>
</div>
<form class="search" action="/html5/projects/{quote(project_id)}/editor" method="get" data-html5-search>
<input name="q" value="{escape(q)}" placeholder="Найти символ на сервере" />
<button type="submit">Найти</button>
</form>
</div>
<pre class="code" data-html5-source>{escape(source_text)}</pre>
</section>
<aside class="panel inspector" data-html5-inspector>
<div class="panel-title">Серверный контекст</div>
<dl class="metrics">
<div><dt>Nodes</dt><dd>{len(snapshot.nodes)}</dd></div>
<div><dt>Edges</dt><dd>{len(snapshot.edges)}</dd></div>
<div><dt>Diagnostics</dt><dd>{len(snapshot.diagnostics)}</dd></div>
<div><dt>Modules</dt><dd>{len(modules)}</dd></div>
</dl>
<div class="panel-title">Типы</div>
<ul class="compact">{''.join(f'<li><span>{escape(kind)}</span><b>{count}</b></li>' for kind, count in counts.most_common(10))}</ul>
<div class="panel-title">Результаты</div>
<div data-html5-symbol-results>
{''.join(_symbol_result(node) for node in search_results) or '<p class="muted">Нет результатов</p>'}
</div>
</aside>
</section>
<footer class="status" data-html5-status hx-ext="sse" sse-connect="/html5/projects/{quote(project_id)}/events" sse-swap="status">
{render_html5_status(project_id, snapshot)}
</footer>
</main>
"""
return _page(f"SFERA HTML5 - {project_id}", content)
def render_html5_status(project_id: str, snapshot: SirSnapshot) -> str:
return (
f'<span>project: {escape(project_id)}</span>'
f'<span>snapshot: {escape(snapshot.snapshot_id)}</span>'
f'<span>nodes: {len(snapshot.nodes)}</span>'
f'<span>edges: {len(snapshot.edges)}</span>'
f'<span>diagnostics: {len(snapshot.diagnostics)}</span>'
'<span>server-rendered</span>'
'<span>client-js: htmx+sse only</span>'
)
def _page(title: str, body: str) -> str:
return f"""<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<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>
</head>
<body>{body}</body>
</html>"""
def _project_row(project: object) -> str:
project_id = str(getattr(project, "project_id", ""))
name = str(getattr(project, "name", project_id))
status = str(getattr(project, "status", "unknown"))
has_snapshot = bool(getattr(project, "has_snapshot", False))
return f"""
<tr data-html5-project="{escape(project_id)}">
<td><strong>{escape(name)}</strong><small>{escape(project_id)}</small></td>
<td>{escape(status)}</td>
<td>{'yes' if has_snapshot else 'no'}</td>
<td><a class="button" href="/html5/projects/{quote(project_id)}/editor">Открыть HTML5</a></td>
</tr>"""
def _project_link(project: object, active_project_id: str) -> str:
project_id = str(getattr(project, "project_id", ""))
name = str(getattr(project, "name", project_id))
active = " active" if project_id == active_project_id else ""
return f'<a class="project-link{active}" href="/html5/projects/{quote(project_id)}/editor">{escape(name)}</a>'
def _topbar(project_id: str, project_nav: str) -> str:
return f"""
<header class="topbar" data-html5-topbar>
<a class="brand" href="/html5">SFERA</a>
<nav class="project-nav">{project_nav}</nav>
<a class="button" href="/docs">API</a>
<a class="button" href="/editor?project={quote(project_id)}">Legacy Next</a>
</header>"""
def _tree_item(node: object) -> str:
name = getattr(node, "qualified_name", None) or getattr(node, "name", "")
kind = getattr(node, "kind", "")
kind_value = str(kind.value if hasattr(kind, "value") else kind)
return f'<a class="tree-item" href="#{quote(str(name))}" data-html5-node-kind="{escape(kind_value)}"><span>{escape(str(name))}</span><small>{escape(kind_value)}</small></a>'
def _symbol_result(node: object) -> str:
name = getattr(node, "qualified_name", None) or getattr(node, "name", "")
kind = getattr(node, "kind", "")
kind_value = str(kind.value if hasattr(kind, "value") else kind)
source_path = getattr(node, "source_path", None) or getattr(getattr(node, "source_ref", None), "source_path", None) or ""
line = getattr(node, "line_start", None) or getattr(getattr(node, "source_ref", None), "line_start", None)
location = f"{source_path}:{line}" if source_path and line else source_path or "source unavailable"
return f"""
<article class="symbol" data-html5-symbol>
<strong>{escape(str(name))}</strong>
<span>{escape(kind_value)}</span>
<small>{escape(str(location))}</small>
</article>"""
def _node_source_text(node: object | None) -> str:
if node is None:
return "// Модуль не найден в snapshot.\n// HTML5 IDE показывает серверный fallback без клиентского JS."
attributes = getattr(node, "attributes", {}) or {}
source_text = attributes.get("source_text") or attributes.get("text")
if isinstance(source_text, str) and source_text.strip():
return source_text
name = getattr(node, "qualified_name", None) or getattr(node, "name", "Module")
return f"// {name}\n// Исходный текст не сохранен в snapshot.\n// Сервер уже отрисовал контекст, дерево, поиск и метрики."
def _css() -> str:
return """
:root{color-scheme:light;--bg:#f7f8fb;--card:#fff;--text:#17202a;--muted:#687385;--line:#dce2ea;--brand:#2457d6;--ok:#168457;--warn:#a16207}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 system-ui,-apple-system,Segoe UI,sans-serif}a{color:inherit}
.shell{min-height:100vh;padding:32px}.hero{display:flex;justify-content:space-between;gap:24px;align-items:end;margin:0 auto 24px;max-width:1180px}.hero h1{margin:0;font-size:36px;letter-spacing:0}.lead{max-width:640px;color:var(--muted);font-size:16px}.eyebrow{margin:0 0 8px;color:var(--brand);font-size:12px;font-weight:800;text-transform:uppercase}.hero-metrics{min-width:160px;border:1px solid var(--line);background:var(--card);padding:18px}.hero-metrics strong{display:block;font-size:34px}.hero-metrics span,.muted{color:var(--muted)}
.band,.panel,.editor{border:1px solid var(--line);background:var(--card)}.band{max-width:1180px;margin:auto;padding:18px}.section-title,.topbar,.editor-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.button,button{display:inline-flex;align-items:center;justify-content:center;height:32px;border:1px solid var(--line);background:#fff;padding:0 12px;text-decoration:none;font-weight:700;cursor:pointer}table{width:100%;border-collapse:collapse}th,td{border-top:1px solid var(--line);padding:12px;text-align:left}td small{display:block;color:var(--muted)}
.workspace{min-height:100vh;padding-bottom:34px}.topbar{position:sticky;top:0;z-index:2;height:54px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.94);padding:0 14px}.brand{font-weight:900;text-decoration:none;color:var(--brand)}.project-nav{display:flex;gap:6px;overflow:auto;flex:1}.project-link{white-space:nowrap;text-decoration:none;border:1px solid var(--line);padding:6px 10px;background:#fff}.project-link.active{background:var(--brand);border-color:var(--brand);color:#fff}
.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}.code{height:calc(100% - 72px);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)}.symbol{display:grid;gap:3px;padding:10px 12px;border-bottom:1px solid var(--line)}.symbol span,.symbol small{color:var(--muted)}.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}
@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}}
"""
+52 -1
View File
@@ -31,10 +31,11 @@ from collaboration import (
)
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import PlainTextResponse, Response
from fastapi.responses import PlainTextResponse, Response, StreamingResponse
from neo4j import AsyncGraphDatabase
from pydantic import BaseModel, Field
from api_server.html5 import render_html5_editor, render_html5_index, render_html5_status
from impact_engine import object_impact, routine_impact
from incremental_indexer import rebuild_changed_file
from integration_topology import IntegrationKind, build_integration_topology
@@ -1567,6 +1568,56 @@ async def api_health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/html5")
async def html5_index() -> Response:
return Response(
render_html5_index(_project_summaries()),
media_type="text/html; charset=utf-8",
)
@app.get("/html5/projects/{project_id}/editor")
async def html5_project_editor(project_id: str, q: str = "") -> Response:
try:
snapshot = _project_snapshot_or_404(project_id)
html = render_html5_editor(
project_id=project_id,
projects=_project_summaries(),
snapshot=snapshot,
q=q,
)
except HTTPException as error:
html = render_html5_editor(
project_id=project_id,
projects=_project_summaries(),
snapshot=None,
error=str(error.detail),
q=q,
)
return Response(html, media_type="text/html; charset=utf-8")
@app.get("/html5/projects/{project_id}/events")
async def html5_project_events(project_id: str, once: bool = False) -> StreamingResponse:
def stream_status():
while True:
try:
snapshot = _project_snapshot_or_404(project_id)
fragment = render_html5_status(project_id, snapshot)
except HTTPException as error:
fragment = f'<span>project: {project_id}</span><span>error: {error.detail}</span>'
yield f"event: status\ndata: {fragment}\n\n"
if once:
break
time.sleep(5)
return StreamingResponse(
stream_status(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.get("/version")
async def version() -> dict[str, str]:
return {"name": "sfera", "version": "0.1.0"}