407 lines
13 KiB
Python
407 lines
13 KiB
Python
import logging
|
||
|
||
from fasthtml.components import Div, Span
|
||
|
||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||
from myfasthtml.controls.IconsHelper import IconsHelper
|
||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||
from myfasthtml.controls.Properties import Properties, PropertiesConf
|
||
from myfasthtml.controls.helpers import mk
|
||
from myfasthtml.core.commands import Command
|
||
from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions
|
||
from myfasthtml.core.instances import SingleInstance
|
||
from myfasthtml.core.profiler import CumulativeSpan, ProfilingSpan, ProfilingTrace, profiler
|
||
from myfasthtml.icons.fluent import (
|
||
arrow_clockwise20_regular,
|
||
data_pie24_regular,
|
||
text_bullet_list_tree20_filled,
|
||
)
|
||
|
||
logger = logging.getLogger("Profiler")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Span tree renderer — module-level, passed via PropertiesConf.types
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _mk_span_rows(span, depth: int, total_ms: float) -> list:
|
||
"""Recursively build span rows for the tree view.
|
||
|
||
Args:
|
||
span: A ProfilingSpan or CumulativeSpan to render.
|
||
depth: Current nesting depth (controls indentation).
|
||
total_ms: Reference duration used to compute bar widths.
|
||
|
||
Returns:
|
||
List of FT elements, one per span row (depth-first order).
|
||
"""
|
||
rows = []
|
||
indent = [Div(cls="mf-profiler-span-indent") for _ in range(depth)]
|
||
|
||
if isinstance(span, CumulativeSpan):
|
||
pct = (span.total_ms / total_ms * 100) if total_ms > 0 else 0
|
||
duration_cls = _span_duration_cls(span.total_ms)
|
||
badge = Span(
|
||
f"×{span.count} · min {span.min_ms:.2f} · avg {span.avg_ms:.2f} · max {span.max_ms:.2f} ms",
|
||
cls="mf-profiler-cumulative-badge",
|
||
)
|
||
row = Div(
|
||
*indent,
|
||
Div(
|
||
Span(span.name, cls="mf-profiler-span-name"),
|
||
Div(Div(style=f"width:{pct:.1f}%"), cls="mf-profiler-span-bar-bg"),
|
||
Span(f"{span.total_ms:.1f} ms", cls=f"mf-profiler-span-ms {duration_cls}"),
|
||
badge,
|
||
cls="mf-profiler-span-body",
|
||
),
|
||
cls="mf-profiler-span-row",
|
||
)
|
||
rows.append(row)
|
||
|
||
else:
|
||
pct = (span.duration_ms / total_ms * 100) if total_ms > 0 else 0
|
||
duration_cls = _span_duration_cls(span.duration_ms)
|
||
name_cls = "mf-profiler-span-name mf-profiler-span-name-root" if depth == 0 else "mf-profiler-span-name"
|
||
row = Div(
|
||
*indent,
|
||
Div(
|
||
Span(span.name, cls=name_cls),
|
||
Div(Div(cls=f"mf-profiler-span-bar {duration_cls}", style=f"width:{pct:.1f}%"), cls="mf-profiler-span-bar-bg"),
|
||
Span(f"{span.duration_ms:.1f} ms", cls=f"mf-profiler-span-ms {duration_cls}"),
|
||
cls="mf-profiler-span-body",
|
||
),
|
||
cls="mf-profiler-span-row",
|
||
)
|
||
rows.append(row)
|
||
for child in span.children:
|
||
rows.extend(_mk_span_rows(child, depth + 1, total_ms))
|
||
|
||
return rows
|
||
|
||
|
||
def _span_duration_cls(duration_ms: float) -> str:
|
||
"""Return the CSS modifier class for a span duration."""
|
||
if duration_ms < 20:
|
||
return "mf-profiler-fast"
|
||
if duration_ms < 100:
|
||
return "mf-profiler-medium"
|
||
return "mf-profiler-slow"
|
||
|
||
|
||
def _span_tree_renderer(span: ProfilingSpan, trace: ProfilingTrace):
|
||
"""Renderer for ProfilingSpan values in a PropertiesConf.types mapping.
|
||
|
||
Args:
|
||
span: The root span to render as a tree.
|
||
trace: The parent trace, used to compute proportional bar widths.
|
||
|
||
Returns:
|
||
A FT element containing the full span tree.
|
||
"""
|
||
rows = _mk_span_rows(span, 0, trace.total_duration_ms)
|
||
return Div(*rows, cls="mf-profiler-span-tree-content")
|
||
|
||
|
||
class Commands(BaseCommands):
|
||
|
||
def toggle_detail_view(self):
|
||
return Command(
|
||
"ProfilerToggleDetailView",
|
||
"Switch between tree and pie view",
|
||
self._owner,
|
||
self._owner.handle_toggle_detail_view,
|
||
).htmx(target=f"#{self._id}")
|
||
|
||
def toggle_enable(self):
|
||
return Command(
|
||
"ProfilerToggleEnable",
|
||
"Enable / Disable profiler",
|
||
self._owner,
|
||
self._owner.handle_toggle_enable,
|
||
).htmx(target=f"#{self._id}")
|
||
|
||
def clear_traces(self):
|
||
return Command(
|
||
"ProfilerClearTraces",
|
||
"Clear all recorded traces",
|
||
self._owner,
|
||
self._owner.handle_clear_traces,
|
||
icon=IconsHelper.get(MediaActions.Cancel),
|
||
).htmx(target=f"#{self._id}")
|
||
|
||
def refresh(self):
|
||
return Command(
|
||
"ProfilerRefresh",
|
||
"Refresh traces",
|
||
self._owner,
|
||
self._owner.handle_refresh,
|
||
icon=arrow_clockwise20_regular,
|
||
).htmx(target=f"#{self._id}")
|
||
|
||
def select_trace(self, trace_id: str):
|
||
return Command(
|
||
"ProfilerSelectTrace",
|
||
"Display trace details",
|
||
self._owner,
|
||
self._owner.handle_select_trace,
|
||
kwargs={"trace_id": trace_id},
|
||
).htmx(target=f"#{self._id}")
|
||
|
||
|
||
class Profiler(SingleInstance):
|
||
"""In-application profiler UI.
|
||
|
||
Displays all recorded traces in a scrollable list (left) and trace
|
||
details in a resizable panel (right). The toolbar provides enable /
|
||
disable toggle and clear actions via icon-only buttons.
|
||
|
||
Attributes:
|
||
commands: Commands exposed by this control.
|
||
"""
|
||
|
||
def __init__(self, parent, _id=None):
|
||
super().__init__(parent, _id=_id)
|
||
self._panel = Panel(self, conf=PanelConf(show_right_title=False, show_display_right=False))
|
||
self._panel.set_side_visible("right", True)
|
||
self._selected_id: str | None = None
|
||
self._detail_view: str = "tree"
|
||
self.commands = Commands(self)
|
||
logger.debug(f"Profiler created with id={self._id}")
|
||
|
||
# ------------------------------------------------------------------
|
||
# Command handlers
|
||
# ------------------------------------------------------------------
|
||
|
||
def handle_toggle_enable(self):
|
||
"""Toggle profiler.enabled and re-render."""
|
||
profiler.enabled = not profiler.enabled
|
||
logger.debug(f"Profiler enabled set to {profiler.enabled}")
|
||
return self
|
||
|
||
def handle_clear_traces(self):
|
||
"""Clear the trace buffer and re-render."""
|
||
profiler.clear()
|
||
logger.debug("Profiler traces cleared from UI")
|
||
return self
|
||
|
||
def handle_select_trace(self, trace_id: str):
|
||
"""Select a trace row and re-render to show it highlighted."""
|
||
self._selected_id = trace_id
|
||
return self
|
||
|
||
def handle_toggle_detail_view(self):
|
||
"""Toggle detail panel between tree and pie view."""
|
||
self._detail_view = "pie" if self._detail_view == "tree" else "tree"
|
||
logger.debug(f"Profiler detail view set to {self._detail_view}")
|
||
return self
|
||
|
||
def handle_refresh(self):
|
||
"""Refresh the trace list without changing selection."""
|
||
return self
|
||
|
||
# ------------------------------------------------------------------
|
||
# Private rendering helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
def _duration_cls(self, duration_ms: float) -> str:
|
||
"""Return the CSS modifier class for a given duration."""
|
||
if duration_ms < 20:
|
||
return "mf-profiler-fast"
|
||
if duration_ms < 100:
|
||
return "mf-profiler-medium"
|
||
return "mf-profiler-slow"
|
||
|
||
def _mk_toolbar(self):
|
||
"""Build the icon toolbar with enable/disable, clear and overhead metrics."""
|
||
enable_icon = (
|
||
IconsHelper.get(MediaActions.Pause)
|
||
if profiler.enabled
|
||
else IconsHelper.get(MediaActions.Play)
|
||
)
|
||
enable_tooltip = "Disable profiler" if profiler.enabled else "Enable profiler"
|
||
|
||
overhead = (
|
||
f"Overhead/span: {profiler.overhead_per_span_us:.1f} µs "
|
||
f"Total: {profiler.total_overhead_ms:.3f} ms "
|
||
f"Traces: {len(profiler.traces)} / {PROFILER_MAX_TRACES}"
|
||
)
|
||
|
||
return Div(
|
||
mk.icon(
|
||
enable_icon,
|
||
command=self.commands.toggle_enable(),
|
||
tooltip=enable_tooltip,
|
||
),
|
||
mk.icon(
|
||
command=self.commands.clear_traces(),
|
||
tooltip="Clear traces",
|
||
cls="mf-profiler-btn-danger",
|
||
),
|
||
mk.icon(
|
||
command=self.commands.refresh(),
|
||
),
|
||
Span(overhead, cls="mf-profiler-overhead"),
|
||
cls="mf-profiler-toolbar",
|
||
id=f"tb_{self._id}",
|
||
)
|
||
|
||
def _mk_trace_list(self):
|
||
"""Build the trace list with one clickable row per recorded trace."""
|
||
traces = profiler.traces
|
||
if not traces:
|
||
return Div("No traces recorded.", cls="mf-profiler-empty")
|
||
|
||
rows = []
|
||
for trace in reversed(traces):
|
||
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
|
||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
||
row_cls = "mf-profiler-row mf-profiler-row-selected" if trace.trace_id == self._selected_id else "mf-profiler-row"
|
||
|
||
row = mk.mk(
|
||
Div(
|
||
Div(
|
||
Span(trace.command_name, cls="mf-profiler-cmd"),
|
||
Span(trace.command_description, cls="mf-profiler-cmd-description"),
|
||
cls="mf-profiler-cmd-cell",
|
||
),
|
||
Span(f"{trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-duration {duration_cls}"),
|
||
Span(ts, cls="mf-profiler-ts"),
|
||
cls=row_cls,
|
||
),
|
||
command=self.commands.select_trace(trace.trace_id),
|
||
)
|
||
rows.append(row)
|
||
|
||
return Div(
|
||
Div(
|
||
Span("Command", cls="mf-profiler-col-header"),
|
||
Span("Duration", cls="mf-profiler-col-header mf-profiler-col-right"),
|
||
Span("Time", cls="mf-profiler-col-header mf-profiler-col-right"),
|
||
cls="mf-profiler-list-header",
|
||
),
|
||
Div(*rows, cls="mf-profiler-list-body"),
|
||
cls="mf-profiler-list",
|
||
)
|
||
|
||
def _mk_detail_placeholder(self):
|
||
"""Placeholder shown in the right panel before a trace is selected."""
|
||
return Div("Select a trace to view details.", cls="mf-profiler-empty")
|
||
|
||
def _mk_detail_header(self, trace: "ProfilingTrace"):
|
||
"""Build the detail panel header with title and tree/pie toggle.
|
||
|
||
Args:
|
||
trace: The selected trace.
|
||
|
||
Returns:
|
||
A FT element for the detail header.
|
||
"""
|
||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
||
title = Div(
|
||
Span(trace.command_name, cls="mf-profiler-detail-cmd"),
|
||
Span(f" — {trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-detail-duration {duration_cls}"),
|
||
cls="mf-profiler-detail-title",
|
||
)
|
||
tree_cls = "mf-profiler-view-btn mf-profiler-view-btn-active" if self._detail_view == "tree" else "mf-profiler-view-btn"
|
||
pie_cls = "mf-profiler-view-btn mf-profiler-view-btn-active" if self._detail_view == "pie" else "mf-profiler-view-btn"
|
||
toggle = Div(
|
||
mk.icon(text_bullet_list_tree20_filled, command=self.commands.toggle_detail_view(), tooltip="Span tree", cls=tree_cls),
|
||
mk.icon(data_pie24_regular, command=self.commands.toggle_detail_view(), tooltip="Pie chart (coming soon)", cls=pie_cls),
|
||
cls="mf-profiler-view-toggle",
|
||
)
|
||
return Div(title, toggle, cls="mf-profiler-detail-header")
|
||
|
||
def _mk_detail_body(self, trace: "ProfilingTrace"):
|
||
"""Build the scrollable detail body: metadata, kwargs and span breakdown.
|
||
|
||
Args:
|
||
trace: The selected trace.
|
||
|
||
Returns:
|
||
A FT element for the detail body.
|
||
"""
|
||
from types import SimpleNamespace
|
||
|
||
meta_props = Properties(
|
||
self,
|
||
conf=PropertiesConf(
|
||
obj=trace,
|
||
groups={"Metadata": {
|
||
"command": "command_name",
|
||
"description": "command_description",
|
||
"duration_ms": "total_duration_ms",
|
||
"timestamp": "timestamp",
|
||
}},
|
||
),
|
||
_id="-detail-meta",
|
||
)
|
||
|
||
kwargs_obj = SimpleNamespace(**trace.kwargs) if trace.kwargs else SimpleNamespace()
|
||
kwargs_props = Properties(
|
||
self,
|
||
conf=PropertiesConf(obj=kwargs_obj, groups={"kwargs": {"*": ""}}),
|
||
_id="-detail-kwargs",
|
||
)
|
||
|
||
span_props = None
|
||
if trace.root_span is not None:
|
||
span_props = Properties(
|
||
self,
|
||
conf=PropertiesConf(
|
||
obj=trace,
|
||
groups={"Span breakdown": {"root_span": "root_span"}},
|
||
types={ProfilingSpan: _span_tree_renderer},
|
||
),
|
||
_id="-detail-spans",
|
||
)
|
||
|
||
if self._detail_view == "pie":
|
||
pie_placeholder = Div("Pie chart — coming soon.", cls="mf-profiler-empty")
|
||
return Div(meta_props, kwargs_props, pie_placeholder, cls="mf-profiler-detail-body")
|
||
|
||
return Div(meta_props, kwargs_props, span_props, cls="mf-profiler-detail-body")
|
||
|
||
def _mk_detail_panel(self, trace: "ProfilingTrace"):
|
||
"""Build the full detail panel for a selected trace.
|
||
|
||
Args:
|
||
trace: The selected trace.
|
||
|
||
Returns:
|
||
A FT element for the detail panel.
|
||
"""
|
||
return Div(
|
||
self._mk_detail_header(trace),
|
||
self._mk_detail_body(trace),
|
||
cls="mf-profiler-detail",
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Render
|
||
# ------------------------------------------------------------------
|
||
|
||
def render(self):
|
||
selected_trace = None
|
||
if self._selected_id is not None:
|
||
selected_trace = next(
|
||
(t for t in profiler.traces if t.trace_id == self._selected_id), None
|
||
)
|
||
|
||
right_panel = (
|
||
self._mk_detail_panel(selected_trace)
|
||
if selected_trace is not None
|
||
else self._mk_detail_placeholder()
|
||
)
|
||
|
||
self._panel.set_main(self._mk_trace_list())
|
||
self._panel.set_right(right_panel)
|
||
return Div(
|
||
self._mk_toolbar(),
|
||
self._panel,
|
||
id=self._id,
|
||
cls="mf-profiler",
|
||
)
|
||
|
||
def __ft__(self):
|
||
return self.render()
|