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