Files
sfera/services/api-server/src/api_server/html5_operations.py
T
m 940d7e884b
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
Extract HTML5 operation helpers
2026-05-17 12:36:56 +03:00

224 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from collections import Counter
from html import escape
from typing import Iterable
from urllib.parse import quote
from api_server.html5 import _enum_text, _metric, _page
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"""
<main class="shell" data-html5-page="operations">
<section class="hero">
<div>
<p class="eyebrow">SFERA HTML5</p>
<h1>Операции сервера</h1>
<p class="lead">Очередь фоновых задач отрисовывается API-сервером и обновляется SSE без React runtime.</p>
</div>
<div class="hero-metrics">
<strong>{len(job_list)}</strong>
<span>jobs</span>
</div>
</section>
<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><th></th></tr>
</thead>
<tbody
data-html5-operations-body
sse-swap="operations-jobs"
hx-swap="innerHTML"
>{render_html5_operation_rows(job_list)}</tbody>
</table>
</div>
{render_html5_operation_detail(None)}
</section>
</main>
""",
)
def render_html5_operation_summary(jobs: Iterable[object]) -> str:
job_list = list(jobs)
counts = Counter(_enum_text(getattr(job, "status", "unknown")) for job in job_list)
running = counts.get("RUNNING", 0)
queued = counts.get("QUEUED", 0)
succeeded = counts.get("SUCCEEDED", 0)
failed = counts.get("FAILED", 0)
return f"""
<div
class="ops-summary"
data-html5-operations-summary
>
{_metric("Всего", len(job_list))}
{_metric("В работе", running)}
{_metric("В очереди", queued)}
{_metric("Успешно", succeeded)}
{_metric("Ошибки", failed)}
</div>
"""
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="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 filter_html5_operation_jobs(
jobs: Iterable[object],
*,
project_id: str = "",
status: str = "",
kind: str = "",
limit: int = 50,
) -> list[object]:
normalized_project = project_id.strip().casefold()
normalized_status = status.strip().casefold()
normalized_kind = kind.strip().casefold()
filtered = []
for job in jobs:
payload = getattr(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
filtered.append(job)
return sorted(filtered, key=lambda job: getattr(job, "updated_at", ""), reverse=True)[:limit]
def latest_html5_import_job(jobs: Iterable[object], project_id: str) -> object | None:
import_jobs = [
job
for job in jobs
if (getattr(job, "payload", {}) or {}).get("project_id") == project_id
and _operation_value(getattr(job, "kind", "")) == "SERVER_IMPORT"
]
return max(import_jobs, key=lambda job: getattr(job, "updated_at", "")) if import_jobs else None
def _operation_row(job: object) -> str:
job_id = str(getattr(job, "job_id", ""))
kind = str(getattr(job, "kind", ""))
status = _enum_text(getattr(job, "status", ""))
payload = getattr(job, "payload", {}) or {}
project_id = str(payload.get("project_id") or "")
stage = str(payload.get("stage") or "")
message = str(payload.get("message") or getattr(job, "error", "") or "")
project_link = (
f'<a href="/html5/projects/{quote(project_id)}/setup">{escape(project_id)}</a>'
if project_id
else '<span class="muted">-</span>'
)
return f"""
<tr data-html5-operation="{escape(job_id)}">
<td><strong>{escape(kind)}</strong><small>{escape(job_id)}</small></td>
<td>{project_link}</td>
<td>{escape(status)}</td>
<td>{escape(stage or "-")}</td>
<td>{escape(message or "-")}</td>
<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 _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 _operation_value(value: object) -> str:
return str(getattr(value, "value", value))