Доработка trace html

This commit is contained in:
2026-05-02 23:14:27 +03:00
parent 90422a0c2a
commit 62f08776eb
3 changed files with 83 additions and 177 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "plba"
version = "0.3.7"
version = "0.3.8"
description = "Platform runtime for business applications"
readme = "README.md"
requires-python = ">=3.11"
+69 -167
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from html import escape
from urllib.parse import urlencode
@@ -12,12 +11,6 @@ from app_runtime.control.base import TraceQueryRequest
from app_runtime.contracts.trace import TraceLevel, TraceLogRecord, TraceLogView
@dataclass(frozen=True)
class TraceStepGroup:
step: str
records: tuple[TraceLogRecord, ...]
class TraceRequestParser:
def parse(self, request: Request) -> TraceQueryRequest:
raw_levels = request.query_params.get("levels")
@@ -94,8 +87,7 @@ class TraceResponseRenderer:
def _render_html(self, trace_view: TraceLogView, request: TraceQueryRequest) -> HTMLResponse:
title = escape(f"Trace {trace_view.trace_id}")
header_links = self._related_trace_links(trace_view, request)
sections = self._step_sections(trace_view, request)
lines = self._html_lines(trace_view, request)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
@@ -104,146 +96,55 @@ class TraceResponseRenderer:
<title>{title}</title>
<style>
:root {{
color-scheme: light;
--bg: #f5f1e8;
--panel: #fffaf2;
--line: #d8cdbf;
--ink: #2b241d;
--muted: #766758;
--error: #8f2d1f;
--warning: #9a6b00;
--info: #295f8a;
--debug: #5d5d7a;
--link: #7b2f24;
color-scheme: dark;
--bg: #000000;
--fg: #cfcfc2;
--link: #66d9ef;
--error: #f92672;
--warning: #e6db74;
--info: #a6e22e;
--debug: #ae81ff;
}}
body {{
margin: 0;
background: linear-gradient(180deg, #efe6d8 0%, var(--bg) 100%);
color: var(--ink);
font: 16px/1.5 Georgia, "Times New Roman", serif;
background: var(--bg);
color: var(--fg);
font: 15px/1.45 "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
}}
.page {{
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}}
.card {{
background: var(--panel);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: 0 12px 40px rgba(43, 36, 29, 0.08);
padding: 20px 22px;
margin-bottom: 20px;
}}
h1, h2 {{
margin: 0 0 12px;
font-weight: 700;
}}
h1 {{
font-size: 28px;
}}
h2 {{
font-size: 18px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}}
.meta {{
margin: 0;
color: var(--muted);
}}
.trace-links {{
margin: 12px 0 0;
padding-left: 20px;
}}
.trace-links li {{
margin: 6px 0;
padding: 14px 16px 24px;
}}
a {{
color: var(--link);
text-decoration: none;
}}
a:hover {{
text-decoration: underline;
text-underline-offset: 2px;
}}
.step-section + .step-section {{
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--line);
}}
.step-title {{
margin: 0 0 14px;
font-size: 22px;
line-height: 1.2;
font-weight: 700;
}}
.log-entry + .log-entry {{
margin-top: 14px;
padding-top: 14px;
border-top: 1px dashed var(--line);
}}
.log-meta {{
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
}}
.message {{
.line {{
white-space: pre-wrap;
word-break: break-word;
}}
.attrs {{
margin-top: 8px;
padding: 10px 12px;
border-radius: 8px;
background: #f3ebde;
white-space: pre-wrap;
overflow-x: auto;
font-size: 13px;
}}
.level-ERROR {{
.msg-error {{
color: var(--error);
}}
.level-WARNING {{
.msg-warning {{
color: var(--warning);
}}
.level-INFO {{
.msg-info {{
color: var(--info);
}}
.level-DEBUG {{
.msg-debug {{
color: var(--debug);
}}
@media (max-width: 640px) {{
.page {{
padding: 14px;
}}
.card {{
padding: 16px 14px;
border-radius: 12px;
}}
h1 {{
font-size: 22px;
}}
.step-title {{
font-size: 20px;
padding: 12px 12px 20px;
}}
}}
</style>
</head>
<body>
<div class="page">
<section class="card">
<h1>{title}</h1>
<p class="meta">Parent: {escape(trace_view.parent_id or "")}</p>
<p class="meta">Children: {len(trace_view.child_ids)}</p>
</section>
<section class="card">
<h2>Related Traces</h2>
{header_links}
</section>
<section class="card">
<h2>Logs By Step</h2>
{sections}
</section>
{lines}
</div>
</body>
</html>"""
@@ -259,18 +160,6 @@ class TraceResponseRenderer:
return record.message
return f"{record.message}, {json.dumps(record.attrs_json, ensure_ascii=False, separators=(',', ':'))}"
def _related_trace_links(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str:
links: list[str] = []
if trace_view.parent_id:
links.append(self._trace_link_item("parent", trace_view.parent_id, request))
links.extend(self._trace_link_item("child", child_id, request) for child_id in trace_view.child_ids)
return f"<ul class=\"trace-links\">{''.join(links)}</ul>"
def _trace_link_item(self, label: str, trace_id: str, request: TraceQueryRequest) -> str:
href = escape(self._trace_href(trace_id, request), quote=True)
text = escape(f"{label}: {trace_id}")
return f"<li><a href=\"{href}\">{text}</a></li>"
def _trace_href(self, trace_id: str, request: TraceQueryRequest) -> str:
params = urlencode(
{
@@ -281,41 +170,54 @@ class TraceResponseRenderer:
)
return f"/traces/{trace_id}?{params}"
def _step_sections(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str:
groups = self._step_groups(trace_view.records)
if not groups:
return "<section class=\"step-section\"><h3 class=\"step-title\">No step</h3><div>No log records for selected filters.</div></section>"
return "".join(self._step_section(group, request.include_attrs_json) for group in groups)
def _html_lines(self, trace_view: TraceLogView, request: TraceQueryRequest) -> str:
lines = [
self._html_plain_line(f"trace_id: {self._trace_link(trace_view.trace_id, request)}"),
self._html_plain_line(f"parent_id: {self._optional_trace_link(trace_view.parent_id, request)}"),
self._html_plain_line("child_ids:"),
*(self._html_plain_line(f" - {self._trace_link(child_id, request)}") for child_id in trace_view.child_ids),
self._html_plain_line("--------------------------------------------------"),
]
previous_step: str | None = None
for record in trace_view.records:
current_step = str(record.step or "")
if previous_step is None:
lines.append(self._html_plain_line(escape(current_step)))
lines.append(self._html_plain_line(""))
elif current_step != previous_step:
lines.append(self._html_plain_line(""))
lines.append(self._html_plain_line(escape(current_step)))
lines.append(self._html_plain_line(""))
previous_step = current_step
lines.extend(self._html_message_lines(record, request.include_attrs_json))
return "".join(lines)
def _step_groups(self, records: tuple[TraceLogRecord, ...]) -> tuple[TraceStepGroup, ...]:
grouped: dict[str, list[TraceLogRecord]] = {}
order: list[str] = []
for record in records:
step = str(record.step or "")
if step not in grouped:
grouped[step] = []
order.append(step)
grouped[step].append(record)
return tuple(TraceStepGroup(step=step, records=tuple(grouped[step])) for step in order)
def _html_message_lines(self, record: TraceLogRecord, include_attrs_json: bool) -> list[str]:
lines = [self._html_colored_line(self._text_message(record, include_attrs_json), record.level)]
return lines
def _step_section(self, group: TraceStepGroup, include_attrs_json: bool) -> str:
step_label = escape(group.step) if group.step else "No step"
entries = "".join(self._log_entry(record, include_attrs_json) for record in group.records)
return f"<section class=\"step-section\"><h3 class=\"step-title\">{step_label}</h3>{entries}</section>"
def _html_plain_line(self, content: str) -> str:
return f"<div class=\"line\">{content or '&nbsp;'}</div>"
def _log_entry(self, record: TraceLogRecord, include_attrs_json: bool) -> str:
level = escape(record.level)
status = escape(record.status or "")
timestamp = escape(record.event_time.isoformat())
message = escape(record.message)
attrs = ""
if include_attrs_json:
attrs_payload = escape(json.dumps(record.attrs_json, ensure_ascii=False, indent=2, sort_keys=True))
attrs = f"<div class=\"attrs\">{attrs_payload}</div>"
return (
f"<article class=\"log-entry level-{level}\">"
f"<div class=\"log-meta\">{timestamp} | {level} | {status}</div>"
f"<div class=\"message\">{message}</div>"
f"{attrs}"
f"</article>"
)
def _html_colored_line(self, content: str, level: str) -> str:
level_class = self._level_class(level)
return f"<div class=\"line {level_class}\">{escape(content)}</div>"
def _trace_link(self, trace_id: str, request: TraceQueryRequest) -> str:
href = escape(self._trace_href(trace_id, request), quote=True)
text = escape(trace_id)
return f"<a href=\"{href}\">{text}</a>"
def _optional_trace_link(self, trace_id: str | None, request: TraceQueryRequest) -> str:
if not trace_id:
return ""
return self._trace_link(trace_id, request)
def _level_class(self, level: str) -> str:
if level == "ERROR":
return "msg-error"
if level == "WARNING":
return "msg-warning"
if level == "DEBUG":
return "msg-debug"
return "msg-info"
+13 -9
View File
@@ -201,17 +201,21 @@ def test_trace_endpoint_returns_html_page_with_related_links() -> None:
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
assert "<h2>Logs By Step</h2>" in response.text
assert '<h3 class="step-title">load_stocks</h3>' in response.text
assert '<h3 class="step-title">filter_stocks</h3>' in response.text
assert "background: var(--bg);" in response.text
assert "--bg: #000000;" in response.text
assert "--fg: #cfcfc2;" in response.text
assert '<div class="line">trace_id: <a href="/traces/trace-1?format=html&amp;levels=ERROR%2CWARNING%2CINFO&amp;attrs_json=true">trace-1</a></div>' in response.text
assert '<div class="line">parent_id: <a href="/traces/parent-1?format=html&amp;levels=ERROR%2CWARNING%2CINFO&amp;attrs_json=true">parent-1</a></div>' in response.text
assert '<div class="line">child_ids:</div>' in response.text
assert '<div class="line"> - <a href="/traces/child-1?format=html&amp;levels=ERROR%2CWARNING%2CINFO&amp;attrs_json=true">child-1</a></div>' in response.text
assert '<div class="line"> - <a href="/traces/child-2?format=html&amp;levels=ERROR%2CWARNING%2CINFO&amp;attrs_json=true">child-2</a></div>' in response.text
assert '<div class="line">load_stocks</div>' in response.text
assert '<div class="line">filter_stocks</div>' in response.text
assert "loaded prices" in response.text
assert "filtered suspicious ticker" in response.text
assert "2026-04-28T10:11:12+00:00 | INFO | ok" in response.text
assert "2026-04-28T10:11:12+00:00 | WARNING | degraded" in response.text
assert 'href="/traces/trace-1?format=html&amp;levels=ERROR%2CWARNING%2CINFO&amp;attrs_json=true"' not in response.text
assert 'href="/traces/parent-1?format=html&amp;levels=ERROR%2CWARNING%2CINFO&amp;attrs_json=true"' in response.text
assert 'href="/traces/child-1?format=html&amp;levels=ERROR%2CWARNING%2CINFO&amp;attrs_json=true"' in response.text
assert 'href="/traces/child-2?format=html&amp;levels=ERROR%2CWARNING%2CINFO&amp;attrs_json=true"' in response.text
assert "2026-04-28T10:11:12+00:00 | INFO | ok" not in response.text
assert "2026-04-28T10:11:12+00:00 | WARNING | degraded" not in response.text
assert "Related Traces" not in response.text
def test_trace_endpoint_validates_query_params() -> None: