224 lines
8.4 KiB
Python
224 lines
8.4 KiB
Python
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))
|