First version of Profiler control with right part

This commit is contained in:
2026-03-22 16:40:21 +01:00
parent d3c0381e34
commit 0e1087a614
4 changed files with 440 additions and 19 deletions

View File

@@ -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
------------------------------------------------------------------ */

View File

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

View File

@@ -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"
@@ -184,13 +287,115 @@ class Profiler(SingleInstance):
"""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,

View File

@@ -1,14 +1,39 @@
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):
@@ -30,6 +55,10 @@ class Properties(MultipleInstance):
)
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)
@@ -40,13 +69,37 @@ class Properties(MultipleInstance):
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"