Files
MyFastHtml/src/myfasthtml/controls/Profiler.py

407 lines
13 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.

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()