From 0e1087a614d00787bb90cbab8628d3ba157a32df Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 22 Mar 2026 16:40:21 +0100 Subject: [PATCH] First version of Profiler control with right part --- src/myfasthtml/assets/core/profiler.css | 163 ++++++++++++++++++ src/myfasthtml/controls/Panel.py | 2 +- src/myfasthtml/controls/Profiler.py | 215 +++++++++++++++++++++++- src/myfasthtml/controls/Properties.py | 79 +++++++-- 4 files changed, 440 insertions(+), 19 deletions(-) diff --git a/src/myfasthtml/assets/core/profiler.css b/src/myfasthtml/assets/core/profiler.css index 7c120f6..7e8f397 100644 --- a/src/myfasthtml/assets/core/profiler.css +++ b/src/myfasthtml/assets/core/profiler.css @@ -162,6 +162,169 @@ color: color-mix(in oklab, var(--color-base-content) 45%, transparent); } +/* ------------------------------------------------------------------ + Detail panel — right side + ------------------------------------------------------------------ */ +.mf-profiler-detail { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; +} + +.mf-profiler-detail-header { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + background: var(--color-base-200); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.mf-profiler-detail-title { + flex: 1; + font-family: var(--font-sans); + font-size: var(--text-sm); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mf-profiler-detail-cmd { + font-family: var(--font-sans); +} + +.mf-profiler-detail-duration { + font-family: var(--font-mono); + font-size: var(--text-xs); +} + +.mf-profiler-view-toggle { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +.mf-profiler-view-btn-active { + color: var(--color-primary) !important; + background: color-mix(in oklab, var(--color-primary) 12%, transparent) !important; +} + +.mf-profiler-detail-body { + flex: 1; + overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +/* ------------------------------------------------------------------ + Span tree — inside a Properties group card + ------------------------------------------------------------------ */ +.mf-profiler-span-tree-content { + display: flex; + flex-direction: column; +} + +.mf-profiler-span-row { + display: flex; + align-items: center; + padding: 4px 10px; + border-bottom: 1px solid color-mix(in oklab, var(--color-border) 50%, transparent); +} + +.mf-profiler-span-row:last-child { + border-bottom: none; +} + +.mf-profiler-span-row:hover { + background: var(--color-base-200); +} + +.mf-profiler-span-indent { + flex-shrink: 0; + width: 14px; + align-self: stretch; + border-left: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent); +} + +.mf-profiler-span-body { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + padding-left: 6px; + min-width: 0; +} + +.mf-profiler-span-name { + min-width: 120px; + font-family: var(--font-sans); + font-size: var(--text-xs); + white-space: nowrap; + flex-shrink: 0; +} + +.mf-profiler-span-name-root { + font-weight: 600; +} + +.mf-profiler-span-bar-bg { + flex: 1; + height: 5px; + background: color-mix(in oklab, var(--color-border) 80%, transparent); + border-radius: 3px; + overflow: hidden; + min-width: 0; +} + +.mf-profiler-span-bar { + height: 100%; + border-radius: 3px; + background: var(--color-primary); +} + +.mf-profiler-span-bar.mf-profiler-medium { + background: var(--color-warning); +} + +.mf-profiler-span-bar.mf-profiler-slow { + background: var(--color-error); +} + +.mf-profiler-span-ms { + font-family: var(--font-mono); + font-size: var(--text-xs); + color: color-mix(in oklab, var(--color-base-content) 55%, transparent); + min-width: 60px; + text-align: right; + flex-shrink: 0; +} + +.mf-profiler-span-ms.mf-profiler-medium { + color: var(--color-warning); +} + +.mf-profiler-span-ms.mf-profiler-slow { + color: var(--color-error); +} + +.mf-profiler-cumulative-badge { + font-family: var(--font-mono); + font-size: 10px; + padding: 1px 5px; + border-radius: 3px; + background: color-mix(in oklab, var(--color-primary) 10%, transparent); + border: 1px solid color-mix(in oklab, var(--color-primary) 30%, transparent); + color: var(--color-primary); + flex-shrink: 0; + white-space: nowrap; +} + /* ------------------------------------------------------------------ Empty state ------------------------------------------------------------------ */ diff --git a/src/myfasthtml/controls/Panel.py b/src/myfasthtml/controls/Panel.py index 7b5d414..8223f1b 100644 --- a/src/myfasthtml/controls/Panel.py +++ b/src/myfasthtml/controls/Panel.py @@ -232,7 +232,7 @@ class Panel(MultipleInstance): hide_icon, Div(content, id=self._ids.content(side)), cls=panel_cls, - style=f"width: {self._state.left_width}px;", + style=f"width: {self._state.right_width}px;", id=self._ids.panel(side) ) diff --git a/src/myfasthtml/controls/Profiler.py b/src/myfasthtml/controls/Profiler.py index cab237e..cfb44a5 100644 --- a/src/myfasthtml/controls/Profiler.py +++ b/src/myfasthtml/controls/Profiler.py @@ -5,18 +5,113 @@ 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 profiler -from myfasthtml.icons.fluent import arrow_clockwise20_regular +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", @@ -67,7 +162,9 @@ class Profiler(SingleInstance): 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}") @@ -92,8 +189,14 @@ class Profiler(SingleInstance): 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): - """Select a trace row and re-render to show it highlighted.""" + """Refresh the trace list without changing selection.""" return self # ------------------------------------------------------------------ @@ -149,7 +252,7 @@ class Profiler(SingleInstance): return Div("No traces recorded.", cls="mf-profiler-empty") rows = [] - for trace in traces: + 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" @@ -183,14 +286,116 @@ class Profiler(SingleInstance): 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, + 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(self._mk_detail_placeholder()) + self._panel.set_right(right_panel) return Div( self._mk_toolbar(), self._panel, diff --git a/src/myfasthtml/controls/Properties.py b/src/myfasthtml/controls/Properties.py index e78de5b..ebc751f 100644 --- a/src/myfasthtml/controls/Properties.py +++ b/src/myfasthtml/controls/Properties.py @@ -1,21 +1,46 @@ +from dataclasses import dataclass, field +from typing import Any, Optional + from fasthtml.components import Div from myutils.ProxyObject import ProxyObject from myfasthtml.core.instances import MultipleInstance +@dataclass +class PropertiesConf: + """Declarative configuration for the Properties control. + + Attributes: + obj: The Python object whose attributes are displayed. + groups: Mapping of group name to ProxyObject spec. + types: Mapping of Python type to renderer callable. + Each renderer has the signature ``(value, obj) -> FT``. + """ + + obj: Any = None + groups: Optional[dict] = None + types: Optional[dict] = field(default=None) + + class Properties(MultipleInstance): - def __init__(self, parent, obj=None, groups: dict = None, _id=None): + def __init__(self, parent, obj=None, groups: dict = None, conf: PropertiesConf = None, _id=None): super().__init__(parent, _id=_id) - self.obj = obj - self.groups = groups + if conf is not None: + self.obj = conf.obj + self.groups = conf.groups + self._types = conf.types or {} + else: + self.obj = obj + self.groups = groups + self._types = {} self.properties_by_group = self._create_properties_by_group() - + def set_obj(self, obj, groups: dict = None): self.obj = obj self.groups = groups self.properties_by_group = self._create_properties_by_group() - + def _mk_group_content(self, properties: dict): return Div( *[ @@ -28,25 +53,53 @@ class Properties(MultipleInstance): ], cls="mf-properties-group-content" ) - + def _mk_property_value(self, value): + for t, renderer in self._types.items(): + if isinstance(value, t): + return renderer(value, self.obj) + if isinstance(value, dict): return self._mk_group_content(value) - + if isinstance(value, (list, tuple)): return self._mk_group_content({i: item for i, item in enumerate(value)}) - + return Div(str(value), cls="mf-properties-value", title=str(value)) - + + def _render_group_content(self, proxy) -> Div: + """Render a group's content. + + When the group contains exactly one property whose type is registered in + ``_types``, the type renderer replaces the entire group content (not just + the value cell). This lets custom renderers (e.g. span trees) fill the + full card width without a key/value row wrapper. + + Otherwise, the standard key/value row layout is used. + + Args: + proxy: ProxyObject for this group. + + Returns: + A FT element containing the group content. + """ + properties = proxy.as_dict() + if len(properties) == 1: + k, v = next(iter(properties.items())) + for t, renderer in self._types.items(): + if isinstance(v, t): + return renderer(v, self.obj) + return self._mk_group_content(properties) + def render(self): return Div( *[ Div( Div( Div(group_name if group_name is not None else "", cls="mf-properties-group-header"), - self._mk_group_content(proxy.as_dict()), + self._render_group_content(proxy), cls="mf-properties-group-container" ), cls="mf-properties-group-card" @@ -56,12 +109,12 @@ class Properties(MultipleInstance): id=self._id, cls="mf-properties" ) - + def _create_properties_by_group(self): if self.groups is None: return {None: ProxyObject(self.obj, {"*": ""})} - + return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()} - + def __ft__(self): return self.render()