3 Commits

Author SHA1 Message Date
kodjo 3bcf50f55f Hardened instance creation 2026-03-23 22:10:11 +01:00
kodjo 7f099b14f6 Fixed double Panel double instantiation 2026-03-23 21:41:06 +01:00
kodjo 0e1087a614 First version of Profiler control with right part 2026-03-22 16:40:21 +01:00
8 changed files with 466 additions and 42 deletions
+163
View File
@@ -162,6 +162,169 @@
color: color-mix(in oklab, var(--color-base-content) 45%, transparent); 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 Empty state
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
+4 -4
View File
@@ -26,10 +26,10 @@ class Boundaries(SingleInstance):
Keep the boundaries updated Keep the boundaries updated
""" """
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None): def __init__(self, parent, container_id: str = None, on_resize=None, _id=None):
super().__init__(owner, _id=_id) super().__init__(parent, _id=_id)
self._owner = owner self._owner = parent
self._container_id = container_id or owner.get_id() self._container_id = container_id or parent.get_id()
self._on_resize = on_resize self._on_resize = on_resize
self._commands = Commands(self) self._commands = Commands(self)
self._state = BoundariesState() self._state = BoundariesState()
+6 -5
View File
@@ -4,7 +4,7 @@ from fasthtml.components import Div
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf
from myfasthtml.controls.Panel import Panel from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.Properties import Properties from myfasthtml.controls.Properties import Properties, PropertiesConf
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager from myfasthtml.core.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager
@@ -73,10 +73,11 @@ class InstancesDebugger(SingleInstance):
"Commands": {"*": "commands"}, "Commands": {"*": "commands"},
} }
return self._panel.set_right(Properties(self, return self._panel.set_right(Properties(
InstancesManager.get(session, instance_id), self,
properties_def, conf=PropertiesConf(obj=InstancesManager.get(session, instance_id), groups=properties_def),
_id="-properties")) _id="-properties",
))
def _get_instance_kind(self, instance) -> str: def _get_instance_kind(self, instance) -> str:
"""Determine the instance kind for visualization. """Determine the instance kind for visualization.
+1 -1
View File
@@ -232,7 +232,7 @@ class Panel(MultipleInstance):
hide_icon, hide_icon,
Div(content, id=self._ids.content(side)), Div(content, id=self._ids.content(side)),
cls=panel_cls, cls=panel_cls,
style=f"width: {self._state.left_width}px;", style=f"width: {self._state.right_width}px;",
id=self._ids.panel(side) id=self._ids.panel(side)
) )
+209 -5
View File
@@ -5,18 +5,113 @@ from fasthtml.components import Div, Span
from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.IconsHelper import IconsHelper from myfasthtml.controls.IconsHelper import IconsHelper
from myfasthtml.controls.Panel import Panel, PanelConf from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.Properties import Properties, PropertiesConf
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions
from myfasthtml.core.instances import SingleInstance from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.profiler import profiler from myfasthtml.core.profiler import CumulativeSpan, ProfilingSpan, ProfilingTrace, profiler
from myfasthtml.icons.fluent import arrow_clockwise20_regular from myfasthtml.icons.fluent import (
arrow_clockwise20_regular,
data_pie24_regular,
text_bullet_list_tree20_filled,
)
logger = logging.getLogger("Profiler") 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): 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): def toggle_enable(self):
return Command( return Command(
"ProfilerToggleEnable", "ProfilerToggleEnable",
@@ -67,7 +162,9 @@ class Profiler(SingleInstance):
def __init__(self, parent, _id=None): def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self._panel = Panel(self, conf=PanelConf(show_right_title=False, show_display_right=False)) 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._selected_id: str | None = None
self._detail_view: str = "tree"
self.commands = Commands(self) self.commands = Commands(self)
logger.debug(f"Profiler created with id={self._id}") logger.debug(f"Profiler created with id={self._id}")
@@ -92,8 +189,14 @@ class Profiler(SingleInstance):
self._selected_id = trace_id self._selected_id = trace_id
return self 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): def handle_refresh(self):
"""Select a trace row and re-render to show it highlighted.""" """Refresh the trace list without changing selection."""
return self return self
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -149,7 +252,7 @@ class Profiler(SingleInstance):
return Div("No traces recorded.", cls="mf-profiler-empty") return Div("No traces recorded.", cls="mf-profiler-empty")
rows = [] rows = []
for trace in traces: for trace in reversed(traces):
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}" ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
duration_cls = self._duration_cls(trace.total_duration_ms) 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_cls = "mf-profiler-row mf-profiler-row-selected" if trace.trace_id == self._selected_id else "mf-profiler-row"
@@ -183,14 +286,115 @@ class Profiler(SingleInstance):
def _mk_detail_placeholder(self): def _mk_detail_placeholder(self):
"""Placeholder shown in the right panel before a trace is selected.""" """Placeholder shown in the right panel before a trace is selected."""
return Div("Select a trace to view details.", cls="mf-profiler-empty") 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 # Render
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def render(self): 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_main(self._mk_trace_list())
self._panel.set_right(self._mk_detail_placeholder()) self._panel.set_right(right_panel)
return Div( return Div(
self._mk_toolbar(), self._mk_toolbar(),
self._panel, self._panel,
+71 -22
View File
@@ -1,21 +1,42 @@
from dataclasses import dataclass, field
from typing import Any, Optional
from fasthtml.components import Div from fasthtml.components import Div
from myutils.ProxyObject import ProxyObject from myutils.ProxyObject import ProxyObject
from myfasthtml.core.instances import MultipleInstance 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): class Properties(MultipleInstance):
def __init__(self, parent, obj=None, groups: dict = None, _id=None): def __init__(self, parent, conf: PropertiesConf = None, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.obj = obj self.conf = conf or PropertiesConf()
self.groups = groups self._refresh()
self.properties_by_group = self._create_properties_by_group()
def set_conf(self, conf: PropertiesConf):
def set_obj(self, obj, groups: dict = None): self.conf = conf
self.obj = obj self._refresh()
self.groups = groups
self.properties_by_group = self._create_properties_by_group() def _refresh(self):
self._types = self.conf.types or {}
self._properties_by_group = self._create_properties_by_group()
def _mk_group_content(self, properties: dict): def _mk_group_content(self, properties: dict):
return Div( return Div(
*[ *[
@@ -28,40 +49,68 @@ class Properties(MultipleInstance):
], ],
cls="mf-properties-group-content" cls="mf-properties-group-content"
) )
def _mk_property_value(self, value): def _mk_property_value(self, value):
for t, renderer in self._types.items():
if isinstance(value, t):
return renderer(value, self.conf.obj)
if isinstance(value, dict): if isinstance(value, dict):
return self._mk_group_content(value) return self._mk_group_content(value)
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
return self._mk_group_content({i: item for i, item in enumerate(value)}) return self._mk_group_content({i: item for i, item in enumerate(value)})
return Div(str(value), return Div(str(value),
cls="mf-properties-value", cls="mf-properties-value",
title=str(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
``conf.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.conf.obj)
return self._mk_group_content(properties)
def render(self): def render(self):
return Div( return Div(
*[ *[
Div( Div(
Div( Div(
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"), 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-container"
), ),
cls="mf-properties-group-card" cls="mf-properties-group-card"
) )
for group_name, proxy in self.properties_by_group.items() for group_name, proxy in self._properties_by_group.items()
], ],
id=self._id, id=self._id,
cls="mf-properties" cls="mf-properties"
) )
def _create_properties_by_group(self): def _create_properties_by_group(self):
if self.groups is None: if self.conf.groups is None:
return {None: ProxyObject(self.obj, {"*": ""})} return {None: ProxyObject(self.conf.obj, {"*": ""})}
return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()} return {k: ProxyObject(self.conf.obj, v) for k, v in self.conf.groups.items()}
def __ft__(self): def __ft__(self):
return self.render() return self.render()
+11 -4
View File
@@ -1,3 +1,4 @@
import inspect
import logging import logging
import uuid import uuid
from typing import Optional, Literal from typing import Optional, Literal
@@ -32,9 +33,15 @@ class BaseInstance:
if VERBOSE_VERBOSE: if VERBOSE_VERBOSE:
logger.debug(f"Creating new instance of type {cls.__name__}") logger.debug(f"Creating new instance of type {cls.__name__}")
parent = args[0] if len(args) > 0 and isinstance(args[0], BaseInstance) else kwargs.get("parent", None) sig = inspect.signature(cls.__init__)
session = args[1] if len(args) > 1 and isinstance(args[1], dict) else kwargs.get("session", None) bound = sig.bind_partial(None, *args, **kwargs) # None pour 'self'
_id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None) bound.apply_defaults()
arguments = bound.arguments
parent = arguments.get("parent", None)
session = arguments.get("session", None)
_id = arguments.get("_id", None)
if VERBOSE_VERBOSE: if VERBOSE_VERBOSE:
logger.debug(f" parent={parent}, session={debug_session(session)}, _id={_id}") logger.debug(f" parent={parent}, session={debug_session(session)}, _id={_id}")
@@ -247,7 +254,7 @@ class InstancesManager:
""" """
key = (InstancesManager.get_session_id(session), instance.get_id()) key = (InstancesManager.get_session_id(session), instance.get_id())
if isinstance(instance, SingleInstance) and key in InstancesManager.instances: if key in InstancesManager.instances and not isinstance(instance, UniqueInstance):
raise DuplicateInstanceError(instance) raise DuplicateInstanceError(instance)
InstancesManager.instances[key] = instance InstancesManager.instances[key] = instance
+1 -1
View File
@@ -41,4 +41,4 @@ def db_manager(parent):
@pytest.fixture @pytest.fixture
def dsm(parent, db_manager): def dsm(parent, db_manager):
return DataServicesManager(parent, parent._session) return DataServicesManager(parent)