8 Commits

25 changed files with 4479 additions and 74 deletions

355
docs/Profiler.md Normal file
View File

@@ -0,0 +1,355 @@
# Profiler — Design & Implementation Plan
## Context
Performance issues were identified during keyboard navigation in the DataGrid (173ms server-side
per command call). The HTMX debug traces (via `htmx_debug.js`) confirmed the bottleneck is
server-side. A persistent, in-application profiling system is needed for continuous analysis
across sessions and future investigations.
---
## Implementation Status
| Phase | Item | Status |
|-------|------|--------|
| **Phase 1 — Core** | `profiler.py` — data model + probe mechanisms | ✅ Done |
| **Phase 1 — Core** | `tests/core/test_profiler.py` — full test suite (7 classes) | ✅ Done |
| **Phase 1 — Core** | Hook `utils.py` — Level A `command_span` | ✅ Done |
| **Phase 1 — Core** | Hook `commands.py` — Level B phases | ⏳ Deferred |
| **Phase 2 — Controls** | `Profiler.py` — global layout (toolbar + list) | 🔄 In progress |
| **Phase 2 — Controls** | `Profiler.py` — detail panel (span tree + pie) | ⏳ Pending |
| **Phase 2 — Controls** | CSS `profiler.css` | 🔄 In progress |
| **Phase 2 — Controls** | `ProfilerPieChart.py` | ⏳ Future |
---
## Design Decisions
### Data Collection Strategy
Two complementary levels:
- **Level A** (route handler): One trace per `/myfasthtml/commands` call. Captures total
server-side duration including lookup, execution, and HTMX swap overhead.
- **Level B** (granular spans): Decomposition of each trace into named phases. Activated
by placing probes in the code.
Both levels are active simultaneously. Level A gives the global picture; Level B gives the
breakdown.
### Probe Mechanisms
Four complementary mechanisms, chosen based on the context:
#### 1. Context manager — partial block instrumentation
```python
with profiler.span("oob_swap"):
# only this block is timed
result = build_oob_elements(...)
```
Metadata can be attached during execution:
```python
with profiler.span("query") as span:
rows = db.query(...)
span.set("row_count", len(rows))
```
#### 2. Decorator — full function instrumentation
```python
@profiler.span("callback")
def execute_callback(self, client_response):
...
```
Function arguments are captured automatically. Metadata can be attached via `current_span()`:
```python
@profiler.span("process")
def process(self, rows):
result = do_work(rows)
profiler.current_span().set("row_count", len(result))
return result
```
#### 3. Cumulative span — loop instrumentation
For loops with many iterations. Aggregates instead of creating one span per iteration.
```python
for row in rows:
with profiler.cumulative_span("process_row"):
process(row)
# or as a decorator
@profiler.cumulative_span("process_row")
def process_row(self, row):
...
```
Exposes: `count`, `total`, `min`, `max`, `avg`. Single entry in the trace tree regardless of
iteration count.
#### 4. `trace_all` — class-level static instrumentation
Wraps all methods of a class at definition time. No runtime overhead beyond the spans themselves.
```python
@profiler.trace_all
class DataGrid(MultipleInstance):
def navigate_cell(self, ...): # auto-spanned
...
# Exclude specific methods
@profiler.trace_all(exclude=["__ft__", "render"])
class DataGrid(MultipleInstance):
...
```
Implementation: uses `inspect` to iterate over methods and wraps each with `@profiler.span()`.
No `sys.settrace()` involved — pure static wrapping.
#### 5. `trace_calls` — sub-call exploration
Traces all function calls made within a single function, recursively. Used for exploration
when the bottleneck location is unknown.
```python
@profiler.trace_calls
def navigate_cell(self, ...):
self._update_selection() # auto-traced as child span
self._compute_visible() # auto-traced as child span
db.query(...) # auto-traced as child span
```
Implementation: uses `sys.setprofile()` scoped to the decorated function's execution only.
Overhead is localized to that function's call stack. This is an exploration tool — use it
to identify hotspots, then replace with explicit probes.
### Span Hierarchy
Hierarchy is determined by code nesting via a `ContextVar` stack (async-safe). No explicit
parent references required.
```python
with profiler.span("execute"): # root
with profiler.span("callback"): # child of execute
result = self.callback(...)
with profiler.span("oob_swap"): # sibling of callback
...
```
When a command calls another command, the second command's spans automatically become children
of the first command's active span.
`profiler.current_span()` provides access to the active span from anywhere in the call stack.
### Storage
- **Scope**: Global (all sessions). Profiling measures server behavior, not per-user state.
- **Structure**: `deque` with a configurable maximum size.
- **Default size**: 500 traces (constant `PROFILER_MAX_TRACES`).
- **Eviction**: Oldest traces are dropped when the buffer is full (FIFO).
- **Persistence**: In-memory only. Lost on server restart.
### Toggle and Clear
- `profiler.enabled` — boolean flag. When `False`, all probe mechanisms are no-ops (zero overhead).
- `profiler.clear()` — empties the trace buffer.
- Both are controllable from the UI control.
### Overhead Measurement
The `ProfilingManager` self-profiles its own `span.__enter__` and `span.__exit__` calls.
Exposes:
- `overhead_per_span_us` — average cost of one span boundary in microseconds
- `total_overhead_ms` — estimated total overhead across all active spans
Visible in the UI to verify the profiler does not bias measurements significantly.
---
## Data Model
```
ProfilingTrace
command_name: str
command_id: str
kwargs: dict
timestamp: datetime
total_duration_ms: float
root_span: ProfilingSpan
ProfilingSpan
name: str
start: float (perf_counter)
duration_ms: float
data: dict (attached via span.set())
children: list[ProfilingSpan | CumulativeSpan]
CumulativeSpan
name: str
count: int
total_ms: float
min_ms: float
max_ms: float
avg_ms: float
```
---
## Code Hooks
### `src/myfasthtml/core/utils.py` — route handler (Level A) ✅
```python
command = CommandsManager.get_command(c_id)
if command:
with profiler.command_span(command.name, c_id, client_response or {}):
return command.execute(client_response)
```
### `src/myfasthtml/core/commands.py` — execution phases (Level B) ⏳ Deferred
Planned breakdown inside `Command.execute()`:
```python
def execute(self, client_response=None):
with profiler.span("before_commands"):
...
with profiler.span("callback"):
result = self.callback(...)
with profiler.span("after_commands"):
...
with profiler.span("oob_swap"):
...
```
Deferred: will be added once the UI control is functional to immediately observe the breakdown.
---
## UI Control Design
### Control name: `Profiler` (SingleInstance)
Single entry point. Replaces the earlier `ProfilerList` name.
**Files:**
- `src/myfasthtml/controls/Profiler.py`
- `src/myfasthtml/assets/core/profiler.css`
### Layout
Split view using `Panel`:
```
┌─────────────────────────────────────────────────────┐
│ [●] [🗑] Overhead/span: 1.2µs Traces: 8/500│ ← toolbar (icon-only)
├──────────────────────┬──────────────────────────────┤
│ Command Duration Time│ NavigateCell — 173.4ms [≡][◕]│
│ ──────────────────────│ ─────────────────────────────│
│ NavigateCell 173ms … │ [Metadata card] │
│ NavigateCell 168ms … │ [kwargs card] │
│ SelectRow 42ms … │ [Span breakdown / Pie chart] │
│ … │ │
└──────────────────────┴──────────────────────────────┘
```
### Toolbar
Icon-only buttons, no `Menu` control (Menu does not support toggle state).
Direct `mk.icon()` calls:
- **Enable/disable**: icon changes between "recording" and "stopped" states based on `profiler.enabled`
- **Clear**: delete icon, always red
- **Refresh**: manual refresh of the trace list (no auto-refresh yet — added in Step 2.1)
Overhead metrics displayed as plain text on the right side of the toolbar.
### Trace list (left panel)
Three columns: command name / duration (color-coded) / timestamp.
Click on a row → update right panel via HTMX.
**Duration color thresholds:**
- Green (`mf-profiler-fast`): < 20 ms
- Orange (`mf-profiler-medium`): 20100 ms
- Red (`mf-profiler-slow`): > 100 ms
### Detail panel (right)
Two view modes, toggled by icons in the detail panel header:
1. **Tree view** (default): Properties-style cards (Metadata, kwargs) + span breakdown with
proportional bars and indentation. Cumulative spans show `×N · min/avg/max` badge.
2. **Pie view**: `ProfilerPieChart` control (future) — distribution of time across spans
at the current zoom level.
The `Properties` control is used as-is for Metadata and kwargs cards.
The span breakdown is custom rendering (not a `Properties` instance).
### Font conventions
- Labels, headings, command names: `--font-sans` (DaisyUI default)
- Values (durations, timestamps, kwargs values): `--font-mono`
- Consistent with `properties.css` (`mf-properties-value` uses `--default-mono-font-family`)
### Visual reference
Mockups available in `examples/`:
- `profiler_mockup.html` — first iteration (monospace font everywhere)
- `profiler_mockup_2.html`**reference** (correct fonts, icon toolbar, tree/pie toggle)
---
## Implementation Plan
### Phase 1 — Core ✅ Complete
1. `ProfilingSpan`, `CumulativeSpan`, `ProfilingTrace` dataclasses
2. `ProfilingManager` with all probe mechanisms
3. `profiler` singleton
4. Hook into `utils.py` (Level A) ✅
5. Hook into `commands.py` (Level B) — deferred
**Tests**: `tests/core/test_profiler.py` — 7 classes, full coverage ✅
### Phase 2 — Controls
#### Step 2.1 — Global layout (current) 🔄
`src/myfasthtml/controls/Profiler.py`:
- `SingleInstance` inheriting
- Toolbar: `mk.icon()` for enable/disable and clear, overhead text
- `Panel` for split layout
- Left: trace list table (command / duration / timestamp), click → select_trace command
- Right: placeholder (empty until Step 2.2)
`src/myfasthtml/assets/core/profiler.css`:
- All `mf-profiler-*` classes
#### Step 2.2 — Detail panel ⏳
Right panel content:
- Metadata and kwargs via `Properties`
- Span tree: custom `_mk_span_tree()` with bars and cumulative badges
- View toggle (tree / pie) in detail header
#### Step 2.3 — Pie chart ⏳ Future
`src/myfasthtml/controls/ProfilerPieChart.py`
---
## Naming Conventions
- Control files: `ProfilerXxx.py`
- CSS classes: `mf-profiler-xxx`
- Logger: `logging.getLogger("Profiler")`
- Constant: `PROFILER_MAX_TRACES = 500` in `src/myfasthtml/core/constants.py`

View File

@@ -0,0 +1,640 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Profiler — UI Mockup</title>
<style>
/* ------------------------------------------------------------------ */
/* Base — mirrors DaisyUI dark theme CSS variables */
/* ------------------------------------------------------------------ */
:root {
--hcg-bg-main: #0d1117;
--hcg-bg-button: rgba(22, 27, 34, 0.92);
--hcg-border: #30363d;
--hcg-text-muted: rgba(230, 237, 243, 0.5);
--hcg-text-primary: #e6edf3;
--hcg-node-bg: #1c2128;
--hcg-node-bg-selected: color-mix(in oklab, #1c2128 70%, #f0883e 30%);
--profiler-danger: #f85149;
--profiler-warn: #e3b341;
--profiler-ok: #3fb950;
--profiler-accent: #58a6ff;
--profiler-muted: rgba(230, 237, 243, 0.35);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--hcg-bg-main);
color: var(--hcg-text-primary);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, monospace;
font-size: 13px;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ------------------------------------------------------------------ */
/* Toolbar */
/* ------------------------------------------------------------------ */
.mf-profiler-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
background: var(--hcg-node-bg);
border-bottom: 1px solid var(--hcg-border);
flex-shrink: 0;
}
.mf-profiler-toolbar-title {
font-size: 13px;
font-weight: 600;
color: var(--hcg-text-primary);
letter-spacing: 0.05em;
text-transform: uppercase;
margin-right: 4px;
}
.mf-profiler-btn {
padding: 4px 12px;
border-radius: 6px;
border: 1px solid var(--hcg-border);
background: var(--hcg-bg-button);
color: var(--hcg-text-primary);
cursor: pointer;
font-size: 12px;
font-family: inherit;
transition: background 0.15s, border-color 0.15s;
}
.mf-profiler-btn:hover {
background: color-mix(in oklab, var(--hcg-node-bg) 80%, var(--profiler-accent) 20%);
border-color: var(--profiler-accent);
}
.mf-profiler-btn.active {
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--profiler-ok) 40%);
border-color: var(--profiler-ok);
color: var(--profiler-ok);
}
.mf-profiler-btn.danger {
border-color: var(--profiler-danger);
color: var(--profiler-danger);
}
.mf-profiler-btn.danger:hover {
background: color-mix(in oklab, var(--hcg-node-bg) 70%, var(--profiler-danger) 30%);
}
.mf-profiler-overhead {
margin-left: auto;
color: var(--hcg-text-muted);
font-size: 11px;
display: flex;
gap: 16px;
}
.mf-profiler-overhead span b {
color: var(--profiler-warn);
}
/* ------------------------------------------------------------------ */
/* Split layout */
/* ------------------------------------------------------------------ */
.mf-profiler-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* ------------------------------------------------------------------ */
/* Trace list (left) */
/* ------------------------------------------------------------------ */
.mf-profiler-list {
width: 380px;
flex-shrink: 0;
border-right: 1px solid var(--hcg-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.mf-profiler-list-header {
display: grid;
grid-template-columns: 1fr 80px 110px;
padding: 6px 12px;
border-bottom: 1px solid var(--hcg-border);
color: var(--hcg-text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--hcg-node-bg);
}
.mf-profiler-list-body {
overflow-y: auto;
flex: 1;
}
.mf-profiler-row {
display: grid;
grid-template-columns: 1fr 80px 110px;
padding: 7px 12px;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
cursor: pointer;
transition: background 0.1s;
align-items: center;
}
.mf-profiler-row:hover {
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--profiler-accent) 5%);
}
.mf-profiler-row.selected {
background: var(--hcg-node-bg-selected);
border-left: 2px solid #f0883e;
padding-left: 10px;
}
.mf-profiler-cmd {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--hcg-text-primary);
}
.mf-profiler-duration {
text-align: right;
font-variant-numeric: tabular-nums;
font-size: 12px;
}
.mf-profiler-duration.fast {
color: var(--profiler-ok);
}
.mf-profiler-duration.medium {
color: var(--profiler-warn);
}
.mf-profiler-duration.slow {
color: var(--profiler-danger);
}
.mf-profiler-ts {
text-align: right;
color: var(--hcg-text-muted);
font-size: 11px;
}
/* ------------------------------------------------------------------ */
/* Detail panel (right) — Properties-style */
/* ------------------------------------------------------------------ */
.mf-profiler-detail {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mf-profiler-detail-header {
padding: 8px 14px;
border-bottom: 1px solid var(--hcg-border);
background: var(--hcg-node-bg);
color: var(--hcg-text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.mf-profiler-detail-header b {
color: var(--hcg-text-primary);
font-size: 13px;
text-transform: none;
letter-spacing: 0;
}
.mf-profiler-detail-body {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* Properties-style cards */
.mf-properties-group-card {
border: 1px solid var(--hcg-border);
border-radius: 6px;
overflow: hidden;
}
.mf-properties-group-header {
padding: 5px 10px;
background: var(--hcg-node-bg);
color: var(--hcg-text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--hcg-border);
}
.mf-properties-row {
display: grid;
grid-template-columns: 140px 1fr;
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
}
.mf-properties-row:last-child {
border-bottom: none;
}
.mf-properties-key {
padding: 5px 10px;
color: var(--hcg-text-muted);
border-right: 1px solid var(--hcg-border);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mf-properties-value {
padding: 5px 10px;
color: var(--hcg-text-primary);
font-size: 12px;
word-break: break-all;
}
/* Span tree */
.mf-profiler-span-tree {
border: 1px solid var(--hcg-border);
border-radius: 6px;
overflow: hidden;
}
.mf-profiler-span-tree-header {
padding: 5px 10px;
background: var(--hcg-node-bg);
color: var(--hcg-text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--hcg-border);
}
.mf-profiler-span-row {
display: flex;
align-items: center;
padding: 5px 10px;
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
gap: 6px;
}
.mf-profiler-span-row:last-child {
border-bottom: none;
}
.mf-profiler-span-indent {
flex-shrink: 0;
}
.mf-profiler-span-bar-wrap {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.mf-profiler-span-name {
min-width: 140px;
font-size: 12px;
white-space: nowrap;
}
.mf-profiler-span-bar-bg {
flex: 1;
height: 6px;
background: rgba(48, 54, 61, 0.6);
border-radius: 3px;
overflow: hidden;
}
.mf-profiler-span-bar {
height: 100%;
border-radius: 3px;
background: var(--profiler-accent);
transition: width 0.2s;
}
.mf-profiler-span-bar.slow {
background: var(--profiler-danger);
}
.mf-profiler-span-bar.medium {
background: var(--profiler-warn);
}
.mf-profiler-span-ms {
font-variant-numeric: tabular-nums;
font-size: 11px;
color: var(--hcg-text-muted);
min-width: 60px;
text-align: right;
}
/* Cumulative span badge */
.mf-profiler-cumulative-badge {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: rgba(88, 166, 255, 0.15);
border: 1px solid rgba(88, 166, 255, 0.3);
color: var(--profiler-accent);
flex-shrink: 0;
}
/* Empty state */
.mf-profiler-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--hcg-text-muted);
font-size: 13px;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--hcg-border);
border-radius: 3px;
}
</style>
</head>
<body>
<!-- ================================================================== -->
<!-- Toolbar -->
<!-- ================================================================== -->
<div class="mf-profiler-toolbar">
<span class="mf-profiler-toolbar-title">Profiler</span>
<button class="mf-profiler-btn active" onclick="toggleEnabled(this)">● Enabled</button>
<button class="mf-profiler-btn danger" onclick="clearTraces()">Clear</button>
<div class="mf-profiler-overhead">
<span>Overhead/span: <b>1.2 µs</b></span>
<span>Total overhead: <b>0.04 ms</b></span>
<span>Traces: <b id="trace-count">8</b> / 500</span>
</div>
</div>
<!-- ================================================================== -->
<!-- Body: list + detail -->
<!-- ================================================================== -->
<div class="mf-profiler-body">
<!-- ---------------------------------------------------------------- -->
<!-- Trace list -->
<!-- ---------------------------------------------------------------- -->
<div class="mf-profiler-list">
<div class="mf-profiler-list-header">
<span>Command</span>
<span style="text-align:right">Duration</span>
<span style="text-align:right">Time</span>
</div>
<div class="mf-profiler-list-body" id="trace-list">
<div class="mf-profiler-row selected" onclick="selectRow(this, 0)">
<span class="mf-profiler-cmd">NavigateCell</span>
<span class="mf-profiler-duration slow">173.4 ms</span>
<span class="mf-profiler-ts">14:32:07.881</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this, 1)">
<span class="mf-profiler-cmd">NavigateCell</span>
<span class="mf-profiler-duration slow">168.1 ms</span>
<span class="mf-profiler-ts">14:32:07.712</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this, 2)">
<span class="mf-profiler-cmd">SelectRow</span>
<span class="mf-profiler-duration medium">42.7 ms</span>
<span class="mf-profiler-ts">14:32:06.501</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this, 3)">
<span class="mf-profiler-cmd">FilterChanged</span>
<span class="mf-profiler-duration medium">38.2 ms</span>
<span class="mf-profiler-ts">14:32:05.334</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this, 4)">
<span class="mf-profiler-cmd">NavigateCell</span>
<span class="mf-profiler-duration fast">12.0 ms</span>
<span class="mf-profiler-ts">14:32:04.102</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this, 5)">
<span class="mf-profiler-cmd">SortColumn</span>
<span class="mf-profiler-duration fast">8.4 ms</span>
<span class="mf-profiler-ts">14:32:03.770</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this, 6)">
<span class="mf-profiler-cmd">SelectRow</span>
<span class="mf-profiler-duration fast">5.1 ms</span>
<span class="mf-profiler-ts">14:32:02.441</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this, 7)">
<span class="mf-profiler-cmd">NavigateCell</span>
<span class="mf-profiler-duration fast">4.8 ms</span>
<span class="mf-profiler-ts">14:32:01.003</span>
</div>
</div>
</div>
<!-- ---------------------------------------------------------------- -->
<!-- Detail panel -->
<!-- ---------------------------------------------------------------- -->
<div class="mf-profiler-detail">
<div class="mf-profiler-detail-header">
Trace detail — <b>NavigateCell</b>
</div>
<div class="mf-profiler-detail-body">
<!-- Metadata (Properties-style) -->
<div class="mf-properties-group-card">
<div class="mf-properties-group-header">Metadata</div>
<div class="mf-properties-row">
<div class="mf-properties-key">command</div>
<div class="mf-properties-value">NavigateCell</div>
</div>
<div class="mf-properties-row">
<div class="mf-properties-key">total_duration_ms</div>
<div class="mf-properties-value" style="color:var(--profiler-danger)">173.4</div>
</div>
<div class="mf-properties-row">
<div class="mf-properties-key">timestamp</div>
<div class="mf-properties-value">2026-03-21 14:32:07.881</div>
</div>
</div>
<!-- kwargs (Properties-style) -->
<div class="mf-properties-group-card">
<div class="mf-properties-group-header">kwargs</div>
<div class="mf-properties-row">
<div class="mf-properties-key">row</div>
<div class="mf-properties-value">12</div>
</div>
<div class="mf-properties-row">
<div class="mf-properties-key">col</div>
<div class="mf-properties-value">3</div>
</div>
<div class="mf-properties-row">
<div class="mf-properties-key">direction</div>
<div class="mf-properties-value">down</div>
</div>
</div>
<!-- Span tree -->
<div class="mf-profiler-span-tree">
<div class="mf-profiler-span-tree-header">Span breakdown</div>
<!-- Root span -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent" style="width:0"></div>
<div class="mf-profiler-span-bar-wrap">
<span class="mf-profiler-span-name" style="color:var(--hcg-text-primary);font-weight:600">NavigateCell</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar slow" style="width:100%"></div>
</div>
<span class="mf-profiler-span-ms" style="color:var(--profiler-danger)">173.4 ms</span>
</div>
</div>
<!-- before_commands -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent"
style="width:16px; border-left:1px solid var(--hcg-border)"></div>
<div class="mf-profiler-span-bar-wrap">
<span class="mf-profiler-span-name">before_commands</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar" style="width:1%"></div>
</div>
<span class="mf-profiler-span-ms">0.8 ms</span>
</div>
</div>
<!-- callback -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent"
style="width:16px; border-left:1px solid var(--hcg-border)"></div>
<div class="mf-profiler-span-bar-wrap">
<span class="mf-profiler-span-name">callback</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar slow" style="width:88%"></div>
</div>
<span class="mf-profiler-span-ms" style="color:var(--profiler-danger)">152.6 ms</span>
</div>
</div>
<!-- navigate_cell (child of callback) -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent"
style="width:32px; border-left:1px solid var(--hcg-border)"></div>
<div class="mf-profiler-span-bar-wrap">
<span class="mf-profiler-span-name">navigate_cell</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar slow" style="width:86%"></div>
</div>
<span class="mf-profiler-span-ms" style="color:var(--profiler-danger)">149.0 ms</span>
</div>
</div>
<!-- process_row (cumulative, child of navigate_cell) -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent"
style="width:48px; border-left:1px solid var(--hcg-border)"></div>
<div class="mf-profiler-span-bar-wrap">
<span class="mf-profiler-span-name">process_row</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar medium" style="width:80%"></div>
</div>
<span class="mf-profiler-span-ms" style="color:var(--profiler-warn)">138.5 ms</span>
</div>
<span class="mf-profiler-cumulative-badge">×1000 · min 0.1 · avg 0.14 · max 0.4 ms</span>
</div>
<!-- after_commands -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent"
style="width:16px; border-left:1px solid var(--hcg-border)"></div>
<div class="mf-profiler-span-bar-wrap">
<span class="mf-profiler-span-name">after_commands</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar" style="width:6%"></div>
</div>
<span class="mf-profiler-span-ms">10.3 ms</span>
</div>
</div>
<!-- oob_swap -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent"
style="width:16px; border-left:1px solid var(--hcg-border)"></div>
<div class="mf-profiler-span-bar-wrap">
<span class="mf-profiler-span-name">oob_swap</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar" style="width:5%"></div>
</div>
<span class="mf-profiler-span-ms">9.7 ms</span>
</div>
</div>
</div><!-- /.mf-profiler-span-tree -->
</div><!-- /.mf-profiler-detail-body -->
</div><!-- /.mf-profiler-detail -->
</div><!-- /.mf-profiler-body -->
<script>
function selectRow(el, index) {
document.querySelectorAll('.mf-profiler-row').forEach(r => r.classList.remove('selected'));
el.classList.add('selected');
}
function toggleEnabled(btn) {
const enabled = btn.classList.toggle('active');
btn.textContent = enabled ? '● Enabled' : '○ Disabled';
}
function clearTraces() {
document.getElementById('trace-list').innerHTML =
'<div class="mf-profiler-empty">No traces recorded.</div>';
document.getElementById('trace-count').textContent = '0';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,920 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Profiler — UI Mockup 2</title>
<style>
/* ------------------------------------------------------------------ */
/* Base — mirrors DaisyUI CSS variables */
/* ------------------------------------------------------------------ */
:root {
--hcg-bg-main: #0d1117;
--hcg-bg-button: rgba(22, 27, 34, 0.92);
--hcg-border: #30363d;
--hcg-text-muted: rgba(230, 237, 243, 0.45);
--hcg-text-primary: #e6edf3;
--hcg-node-bg: #1c2128;
--hcg-node-bg-selected: color-mix(in oklab, #1c2128 70%, #f0883e 30%);
--profiler-danger: #f85149;
--profiler-warn: #e3b341;
--profiler-ok: #3fb950;
--profiler-accent: #58a6ff;
/* Fonts — mirrors myfasthtml.css */
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
--text-xs: 0.6875rem;
--text-sm: 0.8125rem;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--hcg-bg-main);
color: var(--hcg-text-primary);
font-family: var(--font-sans);
font-size: var(--text-sm);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ------------------------------------------------------------------ */
/* Toolbar — icon-only, no Menu control */
/* ------------------------------------------------------------------ */
.mf-profiler-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 5px 10px;
background: var(--hcg-node-bg);
border-bottom: 1px solid var(--hcg-border);
flex-shrink: 0;
}
.mf-profiler-toolbar-sep {
width: 1px;
height: 18px;
background: var(--hcg-border);
margin: 0 6px;
}
/* Icon button — matches mk.icon() style */
.mf-icon-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
border: none;
background: transparent;
color: var(--hcg-text-muted);
cursor: pointer;
transition: background 0.12s, color 0.12s;
position: relative;
}
.mf-icon-btn:hover {
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--hcg-text-primary) 15%);
color: var(--hcg-text-primary);
}
.mf-icon-btn.active {
color: var(--profiler-ok);
}
.mf-icon-btn.active:hover {
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--profiler-ok) 20%);
}
.mf-icon-btn.danger {
color: var(--profiler-danger);
}
.mf-icon-btn.danger:hover {
background: color-mix(in oklab, var(--hcg-node-bg) 70%, var(--profiler-danger) 20%);
}
.mf-icon-btn.view-active {
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--profiler-accent) 25%);
color: var(--profiler-accent);
}
/* Tooltip */
.mf-icon-btn[data-tip]:hover::after {
content: attr(data-tip);
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: #2d333b;
border: 1px solid var(--hcg-border);
border-radius: 4px;
padding: 3px 8px;
font-size: var(--text-xs);
white-space: nowrap;
color: var(--hcg-text-primary);
pointer-events: none;
z-index: 100;
font-family: var(--font-sans);
}
.mf-profiler-overhead {
margin-left: auto;
color: var(--hcg-text-muted);
font-size: var(--text-xs);
font-family: var(--font-sans);
display: flex;
gap: 16px;
}
.mf-profiler-overhead span b {
font-family: var(--font-mono);
color: var(--profiler-warn);
font-weight: 500;
}
/* ------------------------------------------------------------------ */
/* Split layout */
/* ------------------------------------------------------------------ */
.mf-profiler-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* ------------------------------------------------------------------ */
/* Trace list (left) */
/* ------------------------------------------------------------------ */
.mf-profiler-list {
width: 360px;
flex-shrink: 0;
border-right: 1px solid var(--hcg-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.mf-profiler-list-header {
display: grid;
grid-template-columns: 1fr 76px 100px;
padding: 5px 10px;
border-bottom: 1px solid var(--hcg-border);
color: var(--hcg-text-muted);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.06em;
background: var(--hcg-node-bg);
}
.mf-profiler-list-body {
overflow-y: auto;
flex: 1;
}
.mf-profiler-row {
display: grid;
grid-template-columns: 1fr 76px 100px;
padding: 6px 10px;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
cursor: pointer;
transition: background 0.1s;
align-items: center;
}
.mf-profiler-row:hover {
background: color-mix(in oklab, var(--hcg-node-bg) 50%, var(--profiler-accent) 5%);
}
.mf-profiler-row.selected {
background: var(--hcg-node-bg-selected);
border-left: 2px solid #f0883e;
padding-left: 8px;
}
.mf-profiler-cmd {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-sans);
font-size: var(--text-sm);
}
.mf-profiler-duration {
text-align: right;
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.mf-profiler-duration.fast {
color: var(--profiler-ok);
}
.mf-profiler-duration.medium {
color: var(--profiler-warn);
}
.mf-profiler-duration.slow {
color: var(--profiler-danger);
}
.mf-profiler-ts {
text-align: right;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--hcg-text-muted);
}
/* ------------------------------------------------------------------ */
/* Detail panel (right) */
/* ------------------------------------------------------------------ */
.mf-profiler-detail {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mf-profiler-detail-header {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
border-bottom: 1px solid var(--hcg-border);
background: var(--hcg-node-bg);
}
.mf-profiler-detail-title {
font-size: var(--text-sm);
font-family: var(--font-sans);
color: var(--hcg-text-primary);
font-weight: 500;
flex: 1;
}
.mf-profiler-detail-title span {
font-family: var(--font-mono);
color: var(--profiler-accent);
}
/* View toggle in detail header */
.mf-profiler-view-toggle {
display: flex;
gap: 2px;
}
.mf-profiler-detail-body {
flex: 1;
overflow-y: auto;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ------------------------------------------------------------------ */
/* Properties-style cards (reuses properties.css variables) */
/* ------------------------------------------------------------------ */
.mf-properties-group-card {
background: var(--hcg-node-bg);
border: 1px solid var(--hcg-border);
border-radius: 5px;
overflow: hidden;
}
.mf-properties-group-header {
padding: 4px 10px;
background: linear-gradient(135deg,
color-mix(in oklab, var(--profiler-accent) 40%, var(--hcg-node-bg)) 0%,
var(--hcg-node-bg) 100%
);
color: var(--hcg-text-primary);
font-size: var(--text-xs);
font-family: var(--font-sans);
font-weight: 600;
border-bottom: 1px solid var(--hcg-border);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mf-properties-row {
display: grid;
grid-template-columns: 130px 1fr;
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
}
.mf-properties-row:last-child {
border-bottom: none;
}
.mf-properties-row:hover {
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--hcg-text-primary) 3%);
}
.mf-properties-key {
padding: 4px 10px;
color: var(--hcg-text-muted);
font-family: var(--font-sans);
font-size: var(--text-xs);
border-right: 1px solid var(--hcg-border);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mf-properties-value {
padding: 4px 10px;
color: var(--hcg-text-primary);
font-family: var(--font-mono); /* monospace for values */
font-size: var(--text-xs);
word-break: break-all;
}
.mf-properties-value.danger {
color: var(--profiler-danger);
}
/* ------------------------------------------------------------------ */
/* Span tree view */
/* ------------------------------------------------------------------ */
.mf-profiler-span-tree {
border: 1px solid var(--hcg-border);
border-radius: 5px;
overflow: hidden;
}
.mf-profiler-span-tree-header {
padding: 4px 10px;
background: linear-gradient(135deg,
color-mix(in oklab, var(--profiler-accent) 40%, var(--hcg-node-bg)) 0%,
var(--hcg-node-bg) 100%
);
color: var(--hcg-text-primary);
font-size: var(--text-xs);
font-family: var(--font-sans);
font-weight: 600;
border-bottom: 1px solid var(--hcg-border);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mf-profiler-span-row {
display: flex;
align-items: center;
padding: 4px 10px;
border-bottom: 1px solid rgba(48, 54, 61, 0.4);
gap: 0;
}
.mf-profiler-span-row:last-child {
border-bottom: none;
}
.mf-profiler-span-row:hover {
background: color-mix(in oklab, var(--hcg-node-bg) 60%, var(--hcg-text-primary) 3%);
}
.mf-profiler-span-indent {
flex-shrink: 0;
border-left: 1px solid rgba(48, 54, 61, 0.6);
align-self: stretch;
}
.mf-profiler-span-body {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
padding-left: 6px;
}
.mf-profiler-span-name {
min-width: 130px;
font-family: var(--font-sans);
font-size: var(--text-xs);
white-space: nowrap;
}
.mf-profiler-span-name.root {
font-weight: 600;
color: var(--hcg-text-primary);
}
.mf-profiler-span-bar-bg {
flex: 1;
height: 5px;
background: rgba(48, 54, 61, 0.7);
border-radius: 3px;
overflow: hidden;
}
.mf-profiler-span-bar {
height: 100%;
border-radius: 3px;
background: var(--profiler-accent);
}
.mf-profiler-span-bar.slow {
background: var(--profiler-danger);
}
.mf-profiler-span-bar.medium {
background: var(--profiler-warn);
}
.mf-profiler-span-ms {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--hcg-text-muted);
min-width: 58px;
text-align: right;
}
.mf-profiler-span-ms.slow {
color: var(--profiler-danger);
}
.mf-profiler-span-ms.medium {
color: var(--profiler-warn);
}
.mf-profiler-cumulative-badge {
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
background: rgba(88, 166, 255, 0.1);
border: 1px solid rgba(88, 166, 255, 0.25);
color: var(--profiler-accent);
flex-shrink: 0;
white-space: nowrap;
}
/* ------------------------------------------------------------------ */
/* Pie chart view (placeholder) */
/* ------------------------------------------------------------------ */
.mf-profiler-pie-view {
border: 1px solid var(--hcg-border);
border-radius: 5px;
overflow: hidden;
display: none;
}
.mf-profiler-pie-view.visible {
display: block;
}
.mf-profiler-pie-view-header {
padding: 4px 10px;
background: linear-gradient(135deg,
color-mix(in oklab, var(--profiler-accent) 40%, var(--hcg-node-bg)) 0%,
var(--hcg-node-bg) 100%
);
color: var(--hcg-text-primary);
font-size: var(--text-xs);
font-family: var(--font-sans);
font-weight: 600;
border-bottom: 1px solid var(--hcg-border);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mf-profiler-pie-placeholder {
padding: 20px;
display: flex;
gap: 24px;
align-items: center;
justify-content: center;
}
/* SVG pie slices — static mockup */
.mf-profiler-pie-legend {
display: flex;
flex-direction: column;
gap: 6px;
}
.mf-profiler-pie-legend-item {
display: flex;
align-items: center;
gap: 7px;
font-size: var(--text-xs);
font-family: var(--font-sans);
}
.mf-profiler-pie-legend-color {
width: 10px;
height: 10px;
border-radius: 2px;
flex-shrink: 0;
}
.mf-profiler-pie-legend-pct {
font-family: var(--font-mono);
color: var(--hcg-text-muted);
margin-left: auto;
padding-left: 12px;
}
/* Empty state */
.mf-profiler-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--hcg-text-muted);
font-size: var(--text-sm);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--hcg-border);
border-radius: 3px;
}
</style>
</head>
<body>
<!-- ================================================================== -->
<!-- Toolbar — icon-only, no Menu -->
<!-- ================================================================== -->
<div class="mf-profiler-toolbar">
<!-- Enable / Disable toggle -->
<button class="mf-icon-btn active" data-tip="Disable profiler" onclick="toggleEnabled(this)">
<!-- Fluent: record_stop (enabled state) -->
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<circle cx="10" cy="10" r="5"/>
</svg>
</button>
<!-- Clear traces -->
<button class="mf-icon-btn danger" data-tip="Clear traces" onclick="clearTraces()">
<!-- Fluent: delete -->
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M8.5 4h3a.5.5 0 0 0-1 0h-1a.5.5 0 0 0-1 0Zm-1 0a1.5 1.5 0 0 1 3 0h3a.5.5 0 0 1 0 1h-.554l-.853 8.533A1.5 1.5 0 0 1 10.606 15H9.394a1.5 1.5 0 0 1-1.487-1.467L7.054 5H6.5a.5.5 0 0 1 0-1h1Z"/>
</svg>
</button>
<div class="mf-profiler-toolbar-sep"></div>
<!-- Overhead metrics -->
<div class="mf-profiler-overhead">
<span>Overhead/span: <b>1.2 µs</b></span>
<span>Total overhead: <b>0.04 ms</b></span>
<span>Traces: <b id="trace-count">8</b> / 500</span>
</div>
</div>
<!-- ================================================================== -->
<!-- Body: list + detail -->
<!-- ================================================================== -->
<div class="mf-profiler-body">
<!-- ---------------------------------------------------------------- -->
<!-- Trace list -->
<!-- ---------------------------------------------------------------- -->
<div class="mf-profiler-list">
<div class="mf-profiler-list-header">
<span>Command</span>
<span style="text-align:right">Duration</span>
<span style="text-align:right">Time</span>
</div>
<div class="mf-profiler-list-body" id="trace-list">
<div class="mf-profiler-row selected" onclick="selectRow(this)">
<span class="mf-profiler-cmd">NavigateCell</span>
<span class="mf-profiler-duration slow">173.4 ms</span>
<span class="mf-profiler-ts">14:32:07.881</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this)">
<span class="mf-profiler-cmd">NavigateCell</span>
<span class="mf-profiler-duration slow">168.1 ms</span>
<span class="mf-profiler-ts">14:32:07.712</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this)">
<span class="mf-profiler-cmd">SelectRow</span>
<span class="mf-profiler-duration medium">42.7 ms</span>
<span class="mf-profiler-ts">14:32:06.501</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this)">
<span class="mf-profiler-cmd">FilterChanged</span>
<span class="mf-profiler-duration medium">38.2 ms</span>
<span class="mf-profiler-ts">14:32:05.334</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this)">
<span class="mf-profiler-cmd">NavigateCell</span>
<span class="mf-profiler-duration fast">12.0 ms</span>
<span class="mf-profiler-ts">14:32:04.102</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this)">
<span class="mf-profiler-cmd">SortColumn</span>
<span class="mf-profiler-duration fast">8.4 ms</span>
<span class="mf-profiler-ts">14:32:03.770</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this)">
<span class="mf-profiler-cmd">SelectRow</span>
<span class="mf-profiler-duration fast">5.1 ms</span>
<span class="mf-profiler-ts">14:32:02.441</span>
</div>
<div class="mf-profiler-row" onclick="selectRow(this)">
<span class="mf-profiler-cmd">NavigateCell</span>
<span class="mf-profiler-duration fast">4.8 ms</span>
<span class="mf-profiler-ts">14:32:01.003</span>
</div>
</div>
</div>
<!-- ---------------------------------------------------------------- -->
<!-- Detail panel -->
<!-- ---------------------------------------------------------------- -->
<div class="mf-profiler-detail">
<!-- Header with tree/pie toggle -->
<div class="mf-profiler-detail-header">
<span class="mf-profiler-detail-title">
<span>NavigateCell</span> — 173.4 ms
</span>
<div class="mf-profiler-view-toggle">
<!-- Tree view -->
<button class="mf-icon-btn view-active" id="btn-tree" data-tip="Span tree"
onclick="switchView('tree')">
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 4.5A1.5 1.5 0 0 1 4.5 3h11A1.5 1.5 0 0 1 17 4.5v1A1.5 1.5 0 0 1 15.5 7h-11A1.5 1.5 0 0 1 3 5.5v-1ZM3 10a1.5 1.5 0 0 1 1.5-1.5h6A1.5 1.5 0 0 1 12 10v1a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 3 11v-1Zm0 5.5A1.5 1.5 0 0 1 4.5 14h4a1.5 1.5 0 0 1 1.5 1.5v1A1.5 1.5 0 0 1 8.5 18h-4A1.5 1.5 0 0 1 3 16.5v-1Z"/>
</svg>
</button>
<!-- Pie view -->
<button class="mf-icon-btn" id="btn-pie" data-tip="Pie chart"
onclick="switchView('pie')">
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 2a8 8 0 1 1 0 16A8 8 0 0 1 10 2Zm0 1.5A6.5 6.5 0 1 0 16.5 10H10a.5.5 0 0 1-.5-.5V3.5Zm1 .07V9h5.43A6.51 6.51 0 0 0 11 3.57Z"/>
</svg>
</button>
</div>
</div>
<div class="mf-profiler-detail-body">
<!-- Metadata -->
<div class="mf-properties-group-card">
<div class="mf-properties-group-header">Metadata</div>
<div class="mf-properties-row">
<div class="mf-properties-key">command</div>
<div class="mf-properties-value">NavigateCell</div>
</div>
<div class="mf-properties-row">
<div class="mf-properties-key">total_duration_ms</div>
<div class="mf-properties-value danger">173.4</div>
</div>
<div class="mf-properties-row">
<div class="mf-properties-key">timestamp</div>
<div class="mf-properties-value">2026-03-21 14:32:07.881</div>
</div>
</div>
<!-- kwargs -->
<div class="mf-properties-group-card">
<div class="mf-properties-group-header">kwargs</div>
<div class="mf-properties-row">
<div class="mf-properties-key">row</div>
<div class="mf-properties-value">12</div>
</div>
<div class="mf-properties-row">
<div class="mf-properties-key">col</div>
<div class="mf-properties-value">3</div>
</div>
<div class="mf-properties-row">
<div class="mf-properties-key">direction</div>
<div class="mf-properties-value">down</div>
</div>
</div>
<!-- ============================================================ -->
<!-- Span tree view -->
<!-- ============================================================ -->
<div class="mf-profiler-span-tree" id="view-tree">
<div class="mf-profiler-span-tree-header">Span breakdown</div>
<!-- Root -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-body">
<span class="mf-profiler-span-name root">NavigateCell</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar slow" style="width:100%"></div>
</div>
<span class="mf-profiler-span-ms slow">173.4 ms</span>
</div>
</div>
<!-- before_commands — depth 1 -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent" style="width:14px"></div>
<div class="mf-profiler-span-body">
<span class="mf-profiler-span-name">before_commands</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar" style="width:0.5%"></div>
</div>
<span class="mf-profiler-span-ms">0.8 ms</span>
</div>
</div>
<!-- callback — depth 1 -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent" style="width:14px"></div>
<div class="mf-profiler-span-body">
<span class="mf-profiler-span-name">callback</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar slow" style="width:88%"></div>
</div>
<span class="mf-profiler-span-ms slow">152.6 ms</span>
</div>
</div>
<!-- navigate_cell — depth 2 -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent" style="width:14px"></div>
<div class="mf-profiler-span-indent" style="width:14px"></div>
<div class="mf-profiler-span-body">
<span class="mf-profiler-span-name">navigate_cell</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar slow" style="width:86%"></div>
</div>
<span class="mf-profiler-span-ms slow">149.0 ms</span>
</div>
</div>
<!-- process_row cumulative — depth 3 -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent" style="width:14px"></div>
<div class="mf-profiler-span-indent" style="width:14px"></div>
<div class="mf-profiler-span-indent" style="width:14px"></div>
<div class="mf-profiler-span-body">
<span class="mf-profiler-span-name">process_row</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar medium" style="width:80%"></div>
</div>
<span class="mf-profiler-span-ms medium">138.5 ms</span>
</div>
<span class="mf-profiler-cumulative-badge">×1000 · min 0.10 · avg 0.14 · max 0.40 ms</span>
</div>
<!-- after_commands — depth 1 -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent" style="width:14px"></div>
<div class="mf-profiler-span-body">
<span class="mf-profiler-span-name">after_commands</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar" style="width:6%"></div>
</div>
<span class="mf-profiler-span-ms">10.3 ms</span>
</div>
</div>
<!-- oob_swap — depth 1 -->
<div class="mf-profiler-span-row">
<div class="mf-profiler-span-indent" style="width:14px"></div>
<div class="mf-profiler-span-body">
<span class="mf-profiler-span-name">oob_swap</span>
<div class="mf-profiler-span-bar-bg">
<div class="mf-profiler-span-bar" style="width:5.6%"></div>
</div>
<span class="mf-profiler-span-ms">9.7 ms</span>
</div>
</div>
</div><!-- /#view-tree -->
<!-- ============================================================ -->
<!-- Pie chart view (placeholder for ProfilerPieChart) -->
<!-- ============================================================ -->
<div class="mf-profiler-pie-view" id="view-pie">
<div class="mf-profiler-pie-view-header">Distribution</div>
<div class="mf-profiler-pie-placeholder">
<!-- Static SVG pie mockup -->
<svg width="160" height="160" viewBox="0 0 32 32">
<!-- process_row: 80% -->
<circle r="16" cx="16" cy="16" fill="transparent"
stroke="#e3b341" stroke-width="32"
stroke-dasharray="80 100"
transform="rotate(-90) translate(-32)"/>
<!-- callback overhead: 8% -->
<circle r="16" cx="16" cy="16" fill="transparent"
stroke="#58a6ff" stroke-width="32"
stroke-dasharray="8 100"
stroke-dashoffset="-80"
transform="rotate(-90) translate(-32)"/>
<!-- after_commands: 6% -->
<circle r="16" cx="16" cy="16" fill="transparent"
stroke="#3fb950" stroke-width="32"
stroke-dasharray="6 100"
stroke-dashoffset="-88"
transform="rotate(-90) translate(-32)"/>
<!-- oob_swap: 5.6% -->
<circle r="16" cx="16" cy="16" fill="transparent"
stroke="#8b949e" stroke-width="32"
stroke-dasharray="5.6 100"
stroke-dashoffset="-94"
transform="rotate(-90) translate(-32)"/>
<!-- before_commands: ~0.4% -->
<circle r="16" cx="16" cy="16" fill="transparent"
stroke="#6e7681" stroke-width="32"
stroke-dasharray="0.4 100"
stroke-dashoffset="-99.6"
transform="rotate(-90) translate(-32)"/>
</svg>
<div class="mf-profiler-pie-legend">
<div class="mf-profiler-pie-legend-item">
<div class="mf-profiler-pie-legend-color" style="background:#e3b341"></div>
<span>process_row</span>
<span class="mf-profiler-pie-legend-pct">80.0%</span>
</div>
<div class="mf-profiler-pie-legend-item">
<div class="mf-profiler-pie-legend-color" style="background:#58a6ff"></div>
<span>callback</span>
<span class="mf-profiler-pie-legend-pct">8.0%</span>
</div>
<div class="mf-profiler-pie-legend-item">
<div class="mf-profiler-pie-legend-color" style="background:#3fb950"></div>
<span>after_commands</span>
<span class="mf-profiler-pie-legend-pct">6.0%</span>
</div>
<div class="mf-profiler-pie-legend-item">
<div class="mf-profiler-pie-legend-color" style="background:#8b949e"></div>
<span>oob_swap</span>
<span class="mf-profiler-pie-legend-pct">5.6%</span>
</div>
<div class="mf-profiler-pie-legend-item">
<div class="mf-profiler-pie-legend-color" style="background:#6e7681"></div>
<span>before_commands</span>
<span class="mf-profiler-pie-legend-pct">0.4%</span>
</div>
</div>
</div>
</div><!-- /#view-pie -->
</div><!-- /.mf-profiler-detail-body -->
</div><!-- /.mf-profiler-detail -->
</div><!-- /.mf-profiler-body -->
<script>
function selectRow(el) {
document.querySelectorAll('.mf-profiler-row').forEach(r => r.classList.remove('selected'));
el.classList.add('selected');
}
function toggleEnabled(btn) {
const isEnabled = btn.classList.toggle('active');
btn.setAttribute('data-tip', isEnabled ? 'Disable profiler' : 'Enable profiler');
// Icon swap: filled circle = recording, ring = stopped
btn.querySelector('svg').innerHTML = isEnabled
? '<circle cx="10" cy="10" r="5"/>'
: '<circle cx="10" cy="10" r="5" fill="none" stroke="currentColor" stroke-width="2"/>';
}
function clearTraces() {
document.getElementById('trace-list').innerHTML =
'<div class="mf-profiler-empty">No traces recorded.</div>';
document.getElementById('trace-count').textContent = '0';
}
function switchView(view) {
const treeEl = document.getElementById('view-tree');
const pieEl = document.getElementById('view-pie');
const btnTree = document.getElementById('btn-tree');
const btnPie = document.getElementById('btn-pie');
if (view === 'tree') {
treeEl.style.display = '';
pieEl.classList.remove('visible');
btnTree.classList.add('view-active');
btnPie.classList.remove('view-active');
} else {
treeEl.style.display = 'none';
pieEl.classList.add('visible');
btnPie.classList.add('view-active');
btnTree.classList.remove('view-active');
}
}
</script>
</body>
</html>

View File

@@ -13,13 +13,14 @@ from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.Profiler import Profiler
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.dbengine_utils import DataFrameHandler
from myfasthtml.core.instances import UniqueInstance
from myfasthtml.icons.carbon import volume_object_storage
from myfasthtml.icons.fluent_p2 import key_command16_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular, text_edit_style20_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular, text_edit_style20_regular, timer20_regular
from myfasthtml.myfastapp import create_app
with open('logging.yaml', 'r') as f:
@@ -55,13 +56,19 @@ def index(session):
btn_show_instances_debugger = mk.label("Instances",
icon=volume_object_storage,
command=add_tab("Instances", instances_debugger),
id=instances_debugger.get_id())
id=f"l_{instances_debugger.get_id()}")
commands_debugger = CommandsDebugger(layout)
btn_show_commands_debugger = mk.label("Commands",
icon=key_command16_regular,
command=add_tab("Commands", commands_debugger),
id=commands_debugger.get_id())
id=f"l_{commands_debugger.get_id()}")
profiler = Profiler(layout)
btn_show_profiler = mk.label("Profiler",
icon=timer20_regular,
command=add_tab("Profiler", profiler),
id=f"l_{profiler.get_id()}")
btn_file_upload = mk.label("Upload",
icon=folder_open20_regular,
@@ -75,12 +82,14 @@ def index(session):
layout.header_right.add(btn_show_right_drawer)
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
layout.left_drawer.add(btn_show_profiler, "Debugger")
# Parameters
formatting_manager = DataGridFormattingManager(layout)
btn_show_formatting_manager = mk.label("Formatting",
icon=text_edit_style20_regular,
command=add_tab("Formatting", formatting_manager))
command=add_tab("Formatting", formatting_manager),
id=f"l_{formatting_manager.get_id()}")
layout.left_drawer.add(btn_show_formatting_manager, "Parameters")
layout.left_drawer.add(btn_file_upload, "Test")

View File

@@ -0,0 +1,87 @@
/**
* HTMX debug tracing — toggle with window.HTMX_DEBUG = true in the browser console.
*
* Each request gets a unique ID (#1, #2, ...). Timings are deltas from beforeRequest.
*
* Full event sequence (→ = network boundary):
* beforeRequest — request about to be sent
* beforeSend — XHR about to be sent (after HTMX setup)
* → network round-trip ←
* beforeOnLoad — response received, HTMX about to process it
* beforeSwap — DOM swap about to happen
* afterSwap — DOM swap done
* afterSettle — settle phase complete
* afterRequest — full request lifecycle complete
* sendError — network error
* responseError — non-2xx response
*/
window.HTMX_DEBUG = false;
(function () {
console.log('Debug HTMX: htmx.logAll();');
console.log('Perf HTMX: window.HTMX_DEBUG=true;');
})();
(function () {
const EVENTS = [
'htmx:beforeRequest',
'htmx:beforeSend',
'htmx:beforeOnLoad',
'htmx:beforeSwap',
'htmx:afterSwap',
'htmx:afterSettle',
'htmx:afterRequest',
'htmx:sendError',
'htmx:responseError',
];
let counter = 0;
const requests = new WeakMap();
function getInfo(detail) {
const key = detail?.requestConfig ?? detail?.xhr ?? null;
if (!key || !requests.has(key)) return null;
return requests.get(key);
}
EVENTS.forEach(eventName => {
document.addEventListener(eventName, (e) => {
if (!window.HTMX_DEBUG) return;
const short = eventName.replace('htmx:', '').padEnd(14);
const path = e.detail?.requestConfig?.path ?? e.detail?.pathInfo?.requestPath ?? '';
const isError = eventName === 'htmx:sendError' || eventName === 'htmx:responseError';
let prefix;
if (eventName === 'htmx:beforeRequest') {
const key = e.detail?.requestConfig ?? null;
if (key) {
const id = ++counter;
const now = performance.now();
requests.set(key, {id, start: now, last: now});
prefix = `#${String(id).padStart(3)} + 0.0ms (Δ 0.0ms)`;
} else {
prefix = `# ? + 0.0ms (Δ 0.0ms)`;
}
} else {
const info = getInfo(e.detail);
if (info) {
const now = performance.now();
const total = (now - info.start).toFixed(1);
const step = (now - info.last).toFixed(1);
info.last = now;
prefix = `#${String(info.id).padStart(3)} +${String(total).padStart(7)}ms (Δ${String(step).padStart(7)}ms)`;
} else {
prefix = `# ? + ?.?ms (Δ ?.?ms)`;
}
}
if (isError) {
console.warn(`[HTMX] ${prefix} ${short}`, path, e.detail);
} else {
console.debug(`[HTMX] ${prefix} ${short}`, path);
}
});
});
})();

View File

@@ -1,7 +1,15 @@
/**
* Create keyboard bindings
*/
// Set window.KEYBOARD_DEBUG = true in the browser console to enable traces
window.KEYBOARD_DEBUG = false;
(function () {
function kbLog(...args) {
if (window.KEYBOARD_DEBUG) console.debug('[Keyboard]', ...args);
}
/**
* Global registry to store keyboard shortcuts for multiple elements
*/
@@ -172,8 +180,11 @@
// Add snapshot to history
KeyboardRegistry.snapshotHistory.push(snapshot);
kbLog(`key="${key}" | history length=${KeyboardRegistry.snapshotHistory.length} | registeredElements=${KeyboardRegistry.elements.size}`);
// Cancel any pending timeout
if (KeyboardRegistry.pendingTimeout) {
kbLog(` cancelled pending timeout`);
clearTimeout(KeyboardRegistry.pendingTimeout);
KeyboardRegistry.pendingTimeout = null;
KeyboardRegistry.pendingMatches = [];
@@ -198,8 +209,7 @@
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
if (!currentNode) {
// No match in this tree, continue to next element
// console.debug("No match in tree for event", key);
kbLog(` element="${elementId}" → no match in tree`);
continue;
}
@@ -212,6 +222,8 @@
// Check if there are longer sequences possible (node has children)
const hasLongerSequences = currentNode.children.size > 0;
kbLog(` element="${elementId}" | isInside=${isInside} | hasMatch=${hasMatch} | hasLongerSequences=${hasLongerSequences}`);
// Track if ANY element has longer sequences possible
if (hasLongerSequences) {
anyHasLongerSequence = true;
@@ -221,6 +233,7 @@
if (hasMatch) {
const requireInside = currentNode.config["require_inside"] === true;
const enabled = isCombinationEnabled(data.controlDivId, currentNode.combinationStr);
kbLog(` combination="${currentNode.combinationStr}" | requireInside=${requireInside} | enabled=${enabled}`);
if (enabled && (!requireInside || isInside)) {
currentMatches.push({
elementId: elementId,
@@ -228,10 +241,14 @@
combinationStr: currentNode.combinationStr,
isInside: isInside
});
} else {
kbLog(` → skipped (requireInside=${requireInside} but isInside=${isInside}, or disabled)`);
}
}
}
kbLog(` result: matches=${currentMatches.length} | anyHasLongerSequence=${anyHasLongerSequence}`);
// Prevent default if we found any match and not in input context
if (currentMatches.length > 0 && !isInInputContext()) {
event.preventDefault();
@@ -239,6 +256,7 @@
// Decision logic based on matches and longer sequences
if (currentMatches.length > 0 && !anyHasLongerSequence) {
kbLog(` → TRIGGER immediately`);
// We have matches and NO element has longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
@@ -249,6 +267,7 @@
KeyboardRegistry.snapshotHistory = [];
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
kbLog(` → WAITING ${KeyboardRegistry.sequenceTimeout}ms (longer sequence possible)`);
// We have matches but AT LEAST ONE element has longer sequences possible
// Wait for timeout - ALL current matches will be triggered if timeout expires
@@ -256,6 +275,7 @@
const savedEvent = event; // Save event for timeout callback
KeyboardRegistry.pendingTimeout = setTimeout(() => {
kbLog(` → TRIGGER after timeout`);
// Timeout expired, trigger ALL pending matches
for (const match of KeyboardRegistry.pendingMatches) {
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent);
@@ -268,10 +288,12 @@
}, KeyboardRegistry.sequenceTimeout);
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
kbLog(` → WAITING (partial match, no full match yet)`);
// No matches yet but longer sequences are possible
// Just wait, don't trigger anything
} else {
kbLog(` → NO MATCH, clearing history`);
// No matches and no longer sequences possible
// This is an invalid sequence - clear history
KeyboardRegistry.snapshotHistory = [];
@@ -280,11 +302,13 @@
// If we found no match at all, clear the history
// This handles invalid sequences like "A C" when only "A B" exists
if (!foundAnyMatch) {
kbLog(` → foundAnyMatch=false, clearing history`);
KeyboardRegistry.snapshotHistory = [];
}
// Also clear history if it gets too long (prevent memory issues)
if (KeyboardRegistry.snapshotHistory.length > 10) {
kbLog(` → history too long, clearing`);
KeyboardRegistry.snapshotHistory = [];
}
}

View File

@@ -0,0 +1,340 @@
/* ================================================================== */
/* Profiler Control */
/* ================================================================== */
/* ------------------------------------------------------------------
Root wrapper — fills parent, stacks toolbar above panel
------------------------------------------------------------------ */
.mf-profiler {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
font-family: var(--font-sans);
font-size: var(--text-sm);
}
/* ------------------------------------------------------------------
Toolbar
------------------------------------------------------------------ */
.mf-profiler-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 8px;
background: var(--color-base-200);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
/* Danger variant for clear button */
.mf-profiler-btn-danger {
color: var(--color-error) !important;
}
.mf-profiler-btn-danger:hover {
background: color-mix(in oklab, var(--color-error) 15%, transparent) !important;
}
/* Overhead metrics — right-aligned text */
.mf-profiler-overhead {
margin-left: auto;
font-family: var(--font-sans);
font-size: var(--text-xs);
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
white-space: nowrap;
}
/* ------------------------------------------------------------------
Trace list — left panel content
------------------------------------------------------------------ */
.mf-profiler-list {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
.mf-profiler-list-header {
display: grid;
grid-template-columns: 1fr 80px 96px;
padding: 4px 10px;
background: var(--color-base-200);
border-bottom: 1px solid var(--color-border);
font-size: var(--text-xs);
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
text-transform: uppercase;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.mf-profiler-col-header {
font-family: var(--font-sans);
}
.mf-profiler-col-right {
text-align: right;
}
.mf-profiler-list-body {
overflow-y: auto;
flex: 1;
}
/* ------------------------------------------------------------------
Trace row
------------------------------------------------------------------ */
.mf-profiler-row {
display: grid;
grid-template-columns: 1fr 80px 96px;
align-items: center;
padding: 5px 10px;
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
cursor: pointer;
transition: background 0.1s;
}
.mf-profiler-row:hover {
background: var(--color-base-200);
}
.mf-profiler-row-selected {
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
border-left: 2px solid var(--color-primary);
padding-left: 8px;
}
.mf-profiler-row-selected:hover {
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
}
/* Command name + description cell */
.mf-profiler-cmd-cell {
display: flex;
align-items: baseline;
gap: 6px;
overflow: hidden;
}
.mf-profiler-cmd {
font-family: var(--font-sans);
font-size: var(--text-sm);
white-space: nowrap;
flex-shrink: 0;
}
.mf-profiler-cmd-description {
font-family: var(--font-sans);
font-size: var(--text-xs);
font-style: italic;
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Duration cell — monospace, color-coded */
.mf-profiler-duration {
font-family: var(--font-mono);
font-size: var(--text-xs);
text-align: right;
}
.mf-profiler-fast {
color: var(--color-success);
}
.mf-profiler-medium {
color: var(--color-warning);
}
.mf-profiler-slow {
color: var(--color-error);
}
/* Timestamp cell */
.mf-profiler-ts {
font-family: var(--font-mono);
font-size: var(--text-xs);
text-align: right;
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
------------------------------------------------------------------ */
.mf-profiler-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 24px;
font-family: var(--font-sans);
font-size: var(--text-sm);
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
}

View File

@@ -146,6 +146,15 @@
outline: none;
}
.dt2-cell-input {
width: 100%;
}
.dt2-cell-input:focus {
outline: none;
box-shadow: none;
}
.dt2-cell:hover,
.dt2-selected-cell {
background-color: var(--color-selection);

View File

@@ -698,7 +698,7 @@ function updateDatagridSelection(datagridId) {
if (cellElement) {
cellElement.classList.add('dt2-selected-focus');
cellElement.style.userSelect = 'text';
cellElement.focus({ preventScroll: true });
requestAnimationFrame(() => cellElement.focus({ preventScroll: false }));
hasFocusedCell = true;
}
} else if (selectionType === 'cell') {
@@ -750,7 +750,7 @@ function updateDatagridSelection(datagridId) {
if (!hasFocusedCell) {
const grid = document.getElementById(datagridId);
if (grid) grid.focus({ preventScroll: true });
if (grid) requestAnimationFrame(() => grid.focus({ preventScroll: true }));
}
}

View File

@@ -26,10 +26,10 @@ class Boundaries(SingleInstance):
Keep the boundaries updated
"""
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
super().__init__(owner, _id=_id)
self._owner = owner
self._container_id = container_id or owner.get_id()
def __init__(self, parent, container_id: str = None, on_resize=None, _id=None):
super().__init__(parent, _id=_id)
self._owner = parent
self._container_id = container_id or parent.get_id()
self._on_resize = on_resize
self._commands = Commands(self)
self._state = BoundariesState()

View File

@@ -1,3 +1,4 @@
import bisect
import html
import logging
import re
@@ -80,6 +81,7 @@ class DatagridState(DbObject):
self.selection: DatagridSelectionState = DatagridSelectionState()
self.cell_formats: dict = {}
self.table_format: list = []
self.ns_visible_indices: list[int] | None = None
class DatagridSettings(DbObject):
@@ -270,7 +272,7 @@ class DataGrid(MultipleInstance):
self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter())
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
# add Selection Selector
# add Selection Selector (cell, row, column)
selection_types = {
"cell": mk.icon(grid, tooltip="Cell selection"), # default
"row": mk.icon(row, tooltip="Row selection"),
@@ -403,8 +405,12 @@ class DataGrid(MultipleInstance):
if col.visible and col.type != ColumnType.RowSelection_]
def _get_visible_row_indices(self) -> list[int]:
df = self._get_filtered_df()
return list(df.index) if df is not None else []
if self._state.ns_visible_indices is None:
if self._df is None:
self._state.ns_visible_indices = []
else:
self._state.ns_visible_indices = list(self._apply_filter(self._df).index)
return self._state.ns_visible_indices
def _navigate(self, pos: tuple, direction: str) -> tuple:
col_pos, row_index = pos
@@ -421,11 +427,11 @@ class DataGrid(MultipleInstance):
prev_cols = [c for c in navigable_cols if c < col_pos]
return (prev_cols[-1], row_index) if prev_cols else pos
elif direction == "down":
next_rows = [r for r in visible_rows if r > row_index]
return (col_pos, next_rows[0]) if next_rows else pos
next_pos = bisect.bisect_right(visible_rows, row_index)
return (col_pos, visible_rows[next_pos]) if next_pos < len(visible_rows) else pos
elif direction == "up":
prev_rows = [r for r in visible_rows if r < row_index]
return (col_pos, prev_rows[-1]) if prev_rows else pos
prev_pos = bisect.bisect_left(visible_rows, row_index) - 1
return (col_pos, visible_rows[prev_pos]) if prev_pos >= 0 else pos
return pos
@@ -439,9 +445,13 @@ class DataGrid(MultipleInstance):
return None
def _update_current_position(self, pos):
def _update_current_position(self, pos, reset_selection: bool = False):
self._state.selection.last_selected = self._state.selection.selected
self._state.selection.selected = pos
if reset_selection:
self._state.selection.extra_selected.clear()
self._state.edition.under_edition = None
self._state.save()
def _get_format_rules(self, col_pos, row_index, col_def):
@@ -508,14 +518,23 @@ class DataGrid(MultipleInstance):
self._columns.insert(0, DataGridRowSelectionColumnState())
def _enter_edition(self, pos):
logger.debug(f"enter_edition: {pos=}")
col_pos, row_index = pos
col_def = self._columns[col_pos]
if col_def.type in (ColumnType.RowSelection_, ColumnType.RowIndex, ColumnType.Formula):
return self.render_partial()
if col_def.type == ColumnType.Bool:
return self._toggle_bool_cell(col_pos, row_index, col_def)
self._state.edition.under_edition = pos
self._state.save()
return self.render_partial("cell", pos=pos)
def _toggle_bool_cell(self, col_pos, row_index, col_def):
col_array = self._fast_access.get(col_def.col_id)
current_value = col_array[row_index] if col_array is not None and row_index < len(col_array) else False
self._data_service.set_data(col_def.col_id, row_index, not bool(current_value))
return self.render_partial("cell", pos=(col_pos, row_index))
def _convert_edition_value(self, value_str, col_type):
if col_type == ColumnType.Number:
try:
@@ -645,11 +664,13 @@ class DataGrid(MultipleInstance):
def filter(self):
logger.debug("filter")
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
self._state.ns_visible_indices = None
return self.render_partial("body")
def handle_on_click(self, combination, is_inside, cell_id):
logger.debug(f"on_click table={self.get_table_name()} {combination=} {is_inside=} {cell_id=}")
if is_inside and cell_id:
logger.debug(f" is_inside=True")
self._state.selection.extra_selected.clear()
pos = self._get_pos_from_element_id(cell_id)
@@ -659,9 +680,15 @@ class DataGrid(MultipleInstance):
pos == self._state.selection.selected and
self._state.edition.under_edition is None):
return self._enter_edition(pos)
else:
logger.debug(
f" {pos=}, selected={self._state.selection.selected}, under_edition={self._state.edition.under_edition}")
self._update_current_position(pos)
else:
logger.debug(f" is_inside=False")
return self.render_partial()
def on_mouse_selection(self, combination, is_inside, cell_id_mousedown, cell_id_mouseup):
@@ -685,13 +712,15 @@ class DataGrid(MultipleInstance):
def on_key_pressed(self, combination, has_focus, is_inside):
logger.debug(f"on_key_pressed table={self.get_table_name()} {combination=} {has_focus=} {is_inside=}")
if combination == "esc":
self._update_current_position(None)
self._state.selection.extra_selected.clear()
self._update_current_position(None, reset_selection=True)
return self.render_partial("cell", pos=self._state.selection.last_selected)
elif (combination == "enter" and
self._settings.enable_edition and
self._state.selection.selected and
self._state.edition.under_edition is None):
return self._enter_edition(self._state.selection.selected)
elif combination in self._ARROW_KEY_DIRECTIONS:
current_pos = (self._state.selection.selected
or self._state.selection.last_selected
@@ -1139,25 +1168,19 @@ class DataGrid(MultipleInstance):
)
def mk_selection_manager(self):
extra_attr = {
"hx-on::after-settle": f"updateDatagridSelection('{self._id}');",
}
selected = []
if self._state.selection.selected:
# selected.append(("cell", self._get_element_id_from_pos("cell", self._state.selection.selected)))
selected.append(("focus", self._get_element_id_from_pos("cell", self._state.selection.selected)))
for extra_sel in self._state.selection.extra_selected:
selected.append(extra_sel)
return Div(
*[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected],
id=f"tsm_{self._id}",
selection_mode=f"{self._state.selection.selection_mode}",
**extra_attr,
)
def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False):
@@ -1244,7 +1267,8 @@ class DataGrid(MultipleInstance):
id=self._id,
cls="grid",
style="height: 100%; grid-template-rows: auto 1fr;",
tabindex="-1"
tabindex="-1",
**{"hx-on:htmx:after-swap": f"if(event.detail.target.id==='tsm_{self._id}') updateDatagridSelection('{self._id}');"}
)
def render_partial(self, fragment="cell", **kwargs):

View File

@@ -1,13 +1,14 @@
from fastcore.basics import NotStr
from myfasthtml.core.constants import ColumnType
from myfasthtml.core.constants import ColumnType, MediaActions
from myfasthtml.core.utils import pascal_to_snake
from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \
number_row20_regular
from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \
checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_regular
from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular
checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_regular, pause_circle20_regular
from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular, play_circle20_regular, \
dismiss_circle20_regular
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular, record_stop20_regular
default_icons = {
# default type icons
@@ -22,6 +23,12 @@ default_icons = {
"TreeViewFolder": folder20_regular,
"TreeViewFile": document20_regular,
# Media
MediaActions.Play: play_circle20_regular,
MediaActions.Pause: pause_circle20_regular,
MediaActions.Stop: record_stop20_regular,
MediaActions.Cancel: dismiss_circle20_regular,
# Datagrid column icons
ColumnType.RowIndex: number_symbol20_regular,
ColumnType.Text: text_field20_regular,

View File

@@ -4,7 +4,7 @@ from fasthtml.components import Div
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf
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.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager
@@ -73,10 +73,11 @@ class InstancesDebugger(SingleInstance):
"Commands": {"*": "commands"},
}
return self._panel.set_right(Properties(self,
InstancesManager.get(session, instance_id),
properties_def,
_id="-properties"))
return self._panel.set_right(Properties(
self,
conf=PropertiesConf(obj=InstancesManager.get(session, instance_id), groups=properties_def),
_id="-properties",
))
def _get_instance_kind(self, instance) -> str:
"""Determine the instance kind for visualization.

View File

@@ -104,7 +104,7 @@ class Panel(MultipleInstance):
the panel with appropriate HTML elements and JavaScript for interactivity.
"""
def __init__(self, parent, conf: Optional[PanelConf] = None, _id=None):
def __init__(self, parent, conf: Optional[PanelConf] = None, _id="-panel"):
super().__init__(parent, _id=_id)
self.conf = conf or PanelConf()
self.commands = Commands(self)
@@ -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

@@ -0,0 +1,423 @@
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"#tr_{trace_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."""
if self._selected_id is not None:
old_trace = next(trace for trace in profiler.traces if trace.trace_id == self._selected_id)
else:
old_trace = None
self._selected_id = trace_id
trace = next(trace for trace in profiler.traces if trace.trace_id == trace_id)
return (self._mk_trace_item(trace),
self._mk_trace_item(old_trace),
self._panel.set_right(self._mk_right_panel(trace)))
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_item(self, trace: ProfilingTrace):
if trace is None:
return None
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"
return 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,
id=f"tr_{trace.trace_id}",
),
command=self.commands.select_trace(trace.trace_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 = [self._mk_trace_item(trace) for trace in reversed(traces)]
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",
)
def _mk_right_panel(self, trace: "ProfilingTrace"):
"""Build the right panel with a trace detail view."""
return (
self._mk_detail_panel(trace)
if trace is not None
else self._mk_detail_placeholder()
)
# ------------------------------------------------------------------
# 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
)
self._panel.set_main(self._mk_trace_list())
self._panel.set_right(self._mk_right_panel(selected_trace))
return Div(
self._mk_toolbar(),
self._panel,
id=self._id,
cls="mf-profiler",
)
def __ft__(self):
return self.render()

View File

@@ -1,21 +1,42 @@
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, conf: PropertiesConf = None, _id=None):
super().__init__(parent, _id=_id)
self.obj = obj
self.groups = groups
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()
self.conf = conf or PropertiesConf()
self._refresh()
def set_conf(self, conf: PropertiesConf):
self.conf = conf
self._refresh()
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):
return Div(
*[
@@ -28,40 +49,68 @@ 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.conf.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
``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):
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"
)
for group_name, proxy in self.properties_by_group.items()
for group_name, proxy in self._properties_by_group.items()
],
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()}
if self.conf.groups is None:
return {None: ProxyObject(self.conf.obj, {"*": ""})}
return {k: ProxyObject(self.conf.obj, v) for k, v in self.conf.groups.items()}
def __ft__(self):
return self.render()

View File

@@ -2,6 +2,8 @@ from enum import Enum
NO_DEFAULT_VALUE = object()
PROFILER_MAX_TRACES = 500
ROUTE_ROOT = "/myfasthtml"
# Datagrid
@@ -31,6 +33,13 @@ class ColumnType(Enum):
Formula = "Formula"
class MediaActions(Enum):
Play = "Play"
Pause = "Pause"
Stop = "Stop"
Cancel = "Cancel"
def get_columns_types() -> list[ColumnType]:
return [c for c in ColumnType if not c.value.endswith("_")]

View File

@@ -1,3 +1,4 @@
import inspect
import logging
import uuid
from typing import Optional, Literal
@@ -32,9 +33,15 @@ class BaseInstance:
if VERBOSE_VERBOSE:
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)
session = args[1] if len(args) > 1 and isinstance(args[1], dict) else kwargs.get("session", None)
_id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None)
sig = inspect.signature(cls.__init__)
bound = sig.bind_partial(None, *args, **kwargs) # None pour 'self'
bound.apply_defaults()
arguments = bound.arguments
parent = arguments.get("parent", None)
session = arguments.get("session", None)
_id = arguments.get("_id", None)
if VERBOSE_VERBOSE:
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())
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
if key in InstancesManager.instances and not isinstance(instance, UniqueInstance):
raise DuplicateInstanceError(instance)
InstancesManager.instances[key] = instance

View File

@@ -0,0 +1,560 @@
import functools
import inspect
import logging
import sys
import time
from collections import deque
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from uuid import uuid4
logger = logging.getLogger("Profiler")
# ---------------------------------------------------------------------------
# No-op sentinel — used when profiler is disabled
# ---------------------------------------------------------------------------
class _NullSpan:
"""No-op span returned when profiler is disabled."""
def set(self, key: str, value) -> '_NullSpan':
return self
def __enter__(self) -> '_NullSpan':
return self
def __exit__(self, *args):
pass
def __call__(self, fn):
return fn
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class CumulativeSpan:
"""Aggregated span for loops — one entry regardless of iteration count.
Attributes:
name: Span name.
count: Number of iterations recorded.
total_ms: Cumulative duration in milliseconds.
min_ms: Shortest recorded iteration in milliseconds.
max_ms: Longest recorded iteration in milliseconds.
"""
name: str
count: int = 0
total_ms: float = 0.0
min_ms: float = float('inf')
max_ms: float = 0.0
@property
def avg_ms(self) -> float:
"""Average duration per iteration in milliseconds."""
return self.total_ms / self.count if self.count > 0 else 0.0
def record(self, duration_ms: float):
"""Record one iteration.
Args:
duration_ms: Duration of this iteration in milliseconds.
"""
self.count += 1
self.total_ms += duration_ms
if duration_ms < self.min_ms:
self.min_ms = duration_ms
if duration_ms > self.max_ms:
self.max_ms = duration_ms
@dataclass
class ProfilingSpan:
"""A named timing segment.
Attributes:
name: Span name.
data: Arbitrary metadata attached via span.set().
children: Nested spans and cumulative spans.
duration_ms: Duration of this span in milliseconds (set on finish).
"""
name: str
data: dict = field(default_factory=dict)
children: list = field(default_factory=list)
_start: float = field(default_factory=time.perf_counter, repr=False)
duration_ms: float = field(default=0.0)
def set(self, key: str, value) -> 'ProfilingSpan':
"""Attach metadata to this span.
Args:
key: Metadata key.
value: Metadata value.
Returns:
Self, for chaining.
"""
self.data[key] = value
return self
def finish(self):
"""Stop timing and record the duration."""
self.duration_ms = (time.perf_counter() - self._start) * 1000
@dataclass
class ProfilingTrace:
"""One complete command execution, from route handler to response.
Attributes:
command_name: Name of the executed command.
command_description: Human-readable description of the command.
command_id: UUID of the command.
kwargs: Arguments received from the client.
timestamp: When the command was received.
root_span: Top-level span wrapping the full execution.
total_duration_ms: Total server-side duration in milliseconds.
"""
command_name: str
command_description: str
command_id: str
kwargs: dict
timestamp: datetime
root_span: Optional[ProfilingSpan] = None
total_duration_ms: float = 0.0
trace_id: str = field(default_factory=lambda: str(uuid4()))
# ---------------------------------------------------------------------------
# Context managers / decorators
# ---------------------------------------------------------------------------
class _ActiveSpan:
"""Context manager and decorator for a single named span.
When used as a context manager, returns the ProfilingSpan so callers can
attach metadata via ``span.set()``. When used as a decorator, captures
function arguments automatically.
"""
def __init__(self, manager: 'ProfilingManager', name: str, args: dict = None):
self._manager = manager
self._name = name
self._args = args
self._span: Optional[ProfilingSpan] = None
self._token = None
def __enter__(self) -> ProfilingSpan:
if not self._manager.enabled:
return _NullSpan()
overhead_start = time.perf_counter()
self._span = ProfilingSpan(name=self._name)
if self._args:
self._span.data.update(self._args)
self._token = self._manager.push_span(self._span)
self._manager.record_overhead(time.perf_counter() - overhead_start)
return self._span
def __exit__(self, *args):
if self._span is not None:
overhead_start = time.perf_counter()
self._manager.pop_span(self._span, self._token)
self._manager.record_overhead(time.perf_counter() - overhead_start)
def __call__(self, fn):
"""Use as decorator — enabled check deferred to call time."""
manager = self._manager
name = self._name
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not manager.enabled:
return fn(*args, **kwargs)
captured = manager.capture_args(fn, args, kwargs)
with _ActiveSpan(manager, name, captured):
return fn(*args, **kwargs)
return wrapper
class _CumulativeActiveSpan:
"""Context manager and decorator for cumulative spans.
Finds or creates a CumulativeSpan in the current parent and records
each iteration without adding a new child entry.
"""
def __init__(self, manager: 'ProfilingManager', name: str):
self._manager = manager
self._name = name
self._cumulative_span: Optional[CumulativeSpan] = None
self._iter_start: float = 0.0
def _get_or_create(self) -> CumulativeSpan:
parent = self._manager.current_span()
if isinstance(parent, ProfilingSpan):
for child in parent.children:
if isinstance(child, CumulativeSpan) and child.name == self._name:
return child
cumulative_span = CumulativeSpan(name=self._name)
parent.children.append(cumulative_span)
return cumulative_span
return CumulativeSpan(name=self._name)
def __enter__(self) -> CumulativeSpan:
if not self._manager.enabled:
return _NullSpan()
self._cumulative_span = self._get_or_create()
self._iter_start = time.perf_counter()
return self._cumulative_span
def __exit__(self, *args):
if self._cumulative_span is not None:
duration_ms = (time.perf_counter() - self._iter_start) * 1000
self._cumulative_span.record(duration_ms)
def __call__(self, fn):
manager = self._manager
name = self._name
@functools.wraps(fn)
def wrapper(*args, **kwargs):
with _CumulativeActiveSpan(manager, name):
return fn(*args, **kwargs)
return wrapper
class _CommandSpan:
"""Context manager that creates both a ProfilingTrace and its root span.
Used exclusively by the route handler to wrap the full command execution.
"""
def __init__(self,
manager: 'ProfilingManager',
command_name: str,
command_description: str,
command_id: str,
kwargs: dict):
self._manager = manager
self._command_name = command_name
self._command_description = command_description
self._command_id = command_id
self._kwargs = kwargs
self._trace: Optional[ProfilingTrace] = None
self._span: Optional[ProfilingSpan] = None
self._token = None
def __enter__(self) -> ProfilingSpan:
self._trace = ProfilingTrace(
command_name=self._command_name,
command_description=self._command_description,
command_id=self._command_id,
kwargs=dict(self._kwargs) if self._kwargs else {},
timestamp=datetime.now(),
)
self._span = ProfilingSpan(name=self._command_name)
self._token = self._manager.push_span(self._span)
return self._span
def __exit__(self, *args):
self._manager.pop_span(self._span, self._token)
self._trace.root_span = self._span
self._trace.total_duration_ms = self._span.duration_ms
self._manager.add_trace(self._trace)
# ---------------------------------------------------------------------------
# Manager
# ---------------------------------------------------------------------------
class ProfilingManager:
"""Global in-memory profiling manager.
All probe mechanisms check ``enabled`` at call time, so the profiler can
be toggled without restarting the server. Use the module-level ``profiler``
singleton rather than instantiating this class directly.
"""
def __init__(self, max_traces: int = None):
from myfasthtml.core.constants import PROFILER_MAX_TRACES
self.enabled: bool = False
self._traces: deque = deque(maxlen=max_traces or PROFILER_MAX_TRACES)
self._current_span: ContextVar[Optional[ProfilingSpan]] = ContextVar(
'profiler_current_span', default=None
)
self._overhead_samples: list = []
# --- Span lifecycle ---
def push_span(self, span: ProfilingSpan) -> object:
"""Register a span as the current context and attach it to the parent.
Args:
span: The span to activate.
Returns:
A reset token to pass to pop_span().
"""
parent = self._current_span.get()
if isinstance(parent, ProfilingSpan):
parent.children.append(span)
return self._current_span.set(span)
def pop_span(self, span: ProfilingSpan, token: object) -> None:
"""Finish a span and restore the previous context.
Args:
span: The span to finish.
token: The reset token returned by push_span().
"""
span.finish()
self._current_span.reset(token)
def add_trace(self, trace: ProfilingTrace) -> None:
"""Add a completed trace to the buffer.
Args:
trace: The trace to store.
"""
self._traces.appendleft(trace)
def record_overhead(self, duration_s: float) -> None:
"""Record a span boundary overhead sample.
Args:
duration_s: Duration in seconds of the profiler's own housekeeping.
"""
self._overhead_samples.append(duration_s * 1e6)
if len(self._overhead_samples) > 1000:
self._overhead_samples = self._overhead_samples[-1000:]
@staticmethod
def capture_args(fn, args, kwargs) -> dict:
"""Capture function arguments as a truncated string dict.
Args:
fn: The function whose signature is inspected.
args: Positional arguments passed to the function.
kwargs: Keyword arguments passed to the function.
Returns:
Dict of parameter names to string-truncated values.
"""
try:
sig = inspect.signature(fn)
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
params = dict(bound.arguments)
params.pop('self', None)
params.pop('cls', None)
return {k: str(v)[:200] for k, v in params.items()}
except Exception:
return {}
# --- Public interface ---
@property
def traces(self) -> list[ProfilingTrace]:
"""All recorded traces, most recent first."""
return list(self._traces)
def current_span(self) -> Optional[ProfilingSpan]:
"""Return the active span in the current async context.
Can be called from anywhere within a span to attach metadata::
profiler.current_span().set("row_count", len(rows))
"""
return self._current_span.get()
def clear(self):
"""Empty the trace buffer."""
self._traces.clear()
logger.debug("Profiler traces cleared.")
# --- Probe mechanisms ---
def span(self, name: str, args: dict = None) -> _ActiveSpan:
"""Context manager and decorator for a named span.
The enabled check is deferred to call/enter time, so this can be used
as a static decorator without concern for startup order.
Args:
name: Span name.
args: Optional metadata to attach immediately.
Returns:
An object usable as a context manager or decorator.
"""
return _ActiveSpan(self, name, args)
def cumulative_span(self, name: str) -> _CumulativeActiveSpan:
"""Context manager and decorator for loop spans.
Aggregates all iterations into a single entry (count, total, min, max, avg).
Args:
name: Span name.
Returns:
An object usable as a context manager or decorator.
"""
return _CumulativeActiveSpan(self, name)
def command_span(self,
command_name: str,
command_description: str,
command_id: str,
kwargs: dict) -> '_CommandSpan | _NullSpan':
"""Context manager for the route handler.
Creates a ProfilingTrace and its root span together. When the context
exits, the trace is added to the buffer.
Args:
command_name: Human-readable command name.
command_description: Human-readable description of the command.
command_id: UUID string of the command.
kwargs: Client arguments received by the route handler.
Returns:
An object usable as a context manager.
"""
if not self.enabled:
return _NullSpan()
return _CommandSpan(self, command_name, command_description, command_id, kwargs)
def trace_all(self, cls=None, *, exclude: list = None):
"""Class decorator — statically wraps all non-dunder methods with spans.
Wrapping happens at class definition time; the enabled check is deferred
to call time via _ActiveSpan.__call__.
Args:
cls: The class to instrument (when used without parentheses).
exclude: List of method names to skip.
Usage::
@profiler.trace_all
class MyClass: ...
@profiler.trace_all(exclude=["render"])
class MyClass: ...
"""
_exclude = set(exclude or [])
def decorator(klass):
for attr_name, method in inspect.getmembers(klass, predicate=inspect.isfunction):
if attr_name in _exclude:
continue
if attr_name.startswith('__') and attr_name.endswith('__'):
continue
setattr(klass, attr_name, _ActiveSpan(self, attr_name)(method))
return klass
if cls is not None:
return decorator(cls)
return decorator
def trace_calls(self, fn):
"""Function decorator — traces all sub-calls via sys.setprofile().
Use for exploration when the bottleneck location is unknown.
sys.setprofile() is scoped to this function's execution only;
the global profiler is restored on exit.
The root span for ``fn`` itself is created before setprofile is
activated so that profiler internals are not captured as children.
Args:
fn: The function to instrument.
"""
manager = self
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if not manager.enabled:
return fn(*args, **kwargs)
call_stack: list[tuple[ProfilingSpan, object]] = []
# Skip the first call event (fn itself — already represented by root_span)
skip_first = [True]
def _profile(frame, event, arg):
if event == 'call':
if skip_first[0]:
skip_first[0] = False
return
span = ProfilingSpan(name=frame.f_code.co_name)
token = manager.push_span(span)
call_stack.append((span, token))
elif event in ('return', 'exception'):
if call_stack:
span, token = call_stack.pop()
manager.pop_span(span, token)
# Build root span BEFORE activating setprofile so that profiler
# internals (capture_args, ProfilingSpan.__init__, etc.) are not
# captured as children.
captured = manager.capture_args(fn, args, kwargs)
root_span = ProfilingSpan(name=fn.__name__)
root_span.data.update(captured)
root_token = manager.push_span(root_span)
old_profile = sys.getprofile()
sys.setprofile(_profile)
try:
result = fn(*args, **kwargs)
finally:
sys.setprofile(old_profile)
manager.pop_span(root_span, root_token)
return result
return wrapper
# --- Overhead measurement ---
@property
def overhead_per_span_us(self) -> float:
"""Average overhead per span boundary in microseconds."""
if not self._overhead_samples:
return 0.0
return sum(self._overhead_samples) / len(self._overhead_samples)
@property
def total_overhead_ms(self) -> float:
"""Estimated total overhead across all recorded traces."""
total_spans = sum(
self._count_spans(t.root_span) for t in self._traces if t.root_span
)
return (total_spans * self.overhead_per_span_us * 2) / 1000
def _count_spans(self, span: ProfilingSpan) -> int:
if span is None:
return 0
count = 1
for child in span.children:
if isinstance(child, ProfilingSpan):
count += self._count_spans(child)
return count
# ---------------------------------------------------------------------------
# Singleton
# ---------------------------------------------------------------------------
profiler = ProfilingManager()

View File

@@ -11,6 +11,7 @@ from rich.table import Table
from starlette.routing import Mount
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.profiler import profiler
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
from myfasthtml.core.dsl.types import Position
from myfasthtml.core.dsls import DslsManager
@@ -379,8 +380,9 @@ def post(session, c_id: str, client_response: dict = None):
command = CommandsManager.get_command(c_id)
if command:
logger.debug(f"Executing command {command.name}.")
return command.execute(client_response)
with profiler.command_span(command.name, command.description, c_id, client_response or {}):
return command.execute(client_response)
raise ValueError(f"Command with ID '{c_id}' not found.")

View File

@@ -72,6 +72,12 @@ class EndsWith(AttrPredicate):
class Contains(AttrPredicate):
def __init__(self, *value, _word=False):
"""
Initializes the instance with the given value and optional private attribute `_word`.
:param value:
:param _word: Matches the entire word if True, otherwise matches any substring.
"""
super().__init__(value)
self._word = _word

View File

@@ -281,14 +281,14 @@ class TestDataGridBehaviour:
# Selection and Interaction
# ------------------------------------------------------------------
def test_i_can_on_key_pressed_esc_clears_selection(self, datagrid):
def test_i_can_on_key_pressed_esc_clears_selection(self, datagrid_with_data):
"""Test that pressing ESC resets both the focused cell and extra selections.
ESC is the standard 'deselect all' shortcut. Both selected and
extra_selected must be cleared so the grid visually deselects everything
and subsequent navigation starts from a clean state.
"""
dg = datagrid
dg = datagrid_with_data
dg._state.selection.selected = (1, 2)
dg._state.selection.extra_selected.append(("range", (0, 0, 2, 2)))

View File

@@ -0,0 +1,255 @@
import shutil
from datetime import datetime
from uuid import uuid4
import pytest
from fasthtml.common import Div, Span
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.Profiler import Profiler
from myfasthtml.core.instances import InstancesManager
from myfasthtml.core.profiler import profiler, ProfilingTrace
from myfasthtml.test.matcher import matches, find, Contains, TestIcon, DoesNotContain, And, TestObject
def make_trace(
command_name: str = "TestCommand",
duration_ms: float = 50.0,
trace_id: str = None,
) -> ProfilingTrace:
"""Create a fake ProfilingTrace for testing purposes."""
return ProfilingTrace(
command_name=command_name,
command_description=f"{command_name} description",
command_id=str(uuid4()),
kwargs={},
timestamp=datetime.now(),
total_duration_ms=duration_ms,
trace_id=trace_id or str(uuid4()),
)
@pytest.fixture(autouse=True)
def reset_profiler():
"""Reset profiler singleton state before and after each test."""
profiler.clear()
profiler.enabled = False
yield
profiler.clear()
profiler.enabled = False
class TestProfilerBehaviour:
"""Tests for Profiler control behavior and logic."""
@pytest.fixture
def profiler_control(self, root_instance):
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
ctrl = Profiler(root_instance)
yield ctrl
InstancesManager.reset()
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
def test_i_can_create_profiler(self, profiler_control):
"""Test that Profiler initializes with no trace selected."""
assert profiler_control._selected_id is None
@pytest.mark.parametrize("initial", [
False,
True,
])
def test_i_can_toggle_enable(self, profiler_control, initial):
"""Test that handle_toggle_enable inverts profiler.enabled."""
profiler.enabled = initial
profiler_control.handle_toggle_enable()
assert profiler.enabled == (not initial)
def test_i_can_add_traces(self, profiler_control):
trace_a = make_trace("CommandA", 30.0)
trace_b = make_trace("CommandB", 60.0)
profiler._traces.appendleft(trace_a)
profiler._traces.appendleft(trace_b)
assert len(profiler.traces) == 2
assert profiler.traces == [trace_b, trace_a]
def test_i_can_clear_traces_via_handler(self, profiler_control):
"""Test that handle_clear_traces empties the profiler trace buffer."""
profiler._traces.appendleft(make_trace())
profiler_control.handle_clear_traces()
assert len(profiler.traces) == 0
def test_i_can_select_trace_by_id(self, profiler_control):
"""Test that handle_select_trace stores the given trace_id."""
trace_id = str(uuid4())
profiler_control.handle_select_trace(trace_id)
assert profiler_control._selected_id == trace_id
def test_i_can_select_trace_stable_when_new_trace_added(self, profiler_control):
"""Test that selection by trace_id remains correct when a new trace is prepended.
This validates the fix for the index-shift bug: adding a new trace (appendleft)
must not affect which row appears selected.
"""
trace_a = make_trace("CommandA", 30.0)
trace_b = make_trace("CommandB", 60.0)
profiler._traces.appendleft(trace_a)
profiler._traces.appendleft(trace_b)
profiler_control.handle_select_trace(trace_a.trace_id)
# Add a new trace (simulates a new command executing after selection)
profiler._traces.appendleft(make_trace("NewCommand", 10.0))
# Selection still points to trace_a, unaffected by the new prepended trace
assert profiler_control._selected_id == trace_a.trace_id
@pytest.mark.parametrize("duration_ms, expected_cls", [
(10.0, "mf-profiler-fast"),
(50.0, "mf-profiler-medium"),
(150.0, "mf-profiler-slow"),
])
def test_i_can_get_duration_class(self, profiler_control, duration_ms, expected_cls):
"""Test that _duration_cls returns the correct CSS class for each threshold."""
assert profiler_control._duration_cls(duration_ms) == expected_cls
class TestProfilerRender:
"""Tests for Profiler control HTML rendering."""
@pytest.fixture
def profiler_control(self, root_instance):
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
ctrl = Profiler(root_instance)
yield ctrl
InstancesManager.reset()
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
def test_profiler_renders_global_structure(self, profiler_control):
"""Test that Profiler renders with correct global structure.
Why these elements matter:
- id: Required for HTMX targeting (all commands target this id)
- cls Contains "mf-profiler": Root CSS class for layout and styling
- toolbar Div: Always present, contains control actions
- Panel: Always present, hosts trace list and detail panels
"""
html = profiler_control.render()
expected = Div(
Div(cls=Contains("mf-profiler-toolbar")), # toolbar
TestObject(Panel), # panel
id=profiler_control.get_id(),
cls=Contains("mf-profiler"),
)
assert matches(html, expected)
def test_i_can_render_toolbar_when_enabled(self, profiler_control):
"""Test that toolbar shows pause icon when profiler is enabled.
Why these elements matter:
- pause icon: visual indicator that profiler is actively recording
"""
profiler.enabled = True
toolbar = profiler_control._mk_toolbar()
assert matches(toolbar, Div(TestIcon("pause_circle20_regular")))
def test_i_can_render_toolbar_when_disabled(self, profiler_control):
"""Test that toolbar shows play icon when profiler is disabled.
Why these elements matter:
- play icon: visual indicator that profiler is stopped and ready to record
"""
profiler.enabled = False
toolbar = profiler_control._mk_toolbar()
assert matches(toolbar, Div(TestIcon("play_circle20_regular")))
def test_i_can_render_toolbar_clear_button(self, profiler_control):
"""Test that toolbar contains exactly one danger-styled clear button.
Why these elements matter:
- cls Contains "mf-profiler-btn-danger": Ensures the clear button is visually
distinct (red) to warn the user before clearing all traces
"""
toolbar = profiler_control._mk_toolbar()
danger_buttons = find(toolbar, Div(cls=Contains("mf-profiler-btn-danger")))
assert len(danger_buttons) == 1, "Toolbar should contain exactly one danger-styled button"
def test_i_can_render_empty_trace_list(self, profiler_control):
"""Test that an empty-state message is shown when no traces are recorded.
Why these elements matter:
- "No traces recorded.": User-facing feedback when profiler has no data
- cls Contains "mf-profiler-empty": Applies centered empty-state styling
"""
trace_list = profiler_control._mk_trace_list()
assert matches(trace_list, Div("No traces recorded.", cls=Contains("mf-profiler-empty")))
def test_i_can_render_trace_with_name_and_timestamp(self, profiler_control):
"""Test that a trace row shows command name and formatted timestamp.
Why these elements matter:
- Span.mf-profiler-cmd with command_name: primary identifier for the user
- Span.mf-profiler-ts with formatted timestamp: helps correlate traces with events
"""
trace = make_trace("NavigateCell", 50.0)
ts_expected = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
profiler._traces.appendleft(trace)
trace_list = profiler_control._mk_trace_list()
cmd_spans = find(trace_list, Span("NavigateCell", cls=Contains("mf-profiler-cmd", _word=True)))
assert len(cmd_spans) == 1, "Command name should appear exactly once in the trace list"
ts_spans = find(trace_list, Span(ts_expected, cls=Contains("mf-profiler-ts")))
assert len(ts_spans) == 1, "Formatted timestamp should appear exactly once in the trace list"
def test_i_can_render_selected_row_has_selected_class(self, profiler_control):
"""Test that the selected row carries the selected CSS class.
Why these elements matter:
- cls Contains "mf-profiler-row-selected": Visual highlight of the active trace,
matched by trace_id (stable identifier) rather than list index
"""
trace = make_trace("NavigateCell", 50.0)
profiler._traces.appendleft(trace)
profiler_control._selected_id = trace.trace_id
trace_list = profiler_control._mk_trace_list()
selected_rows = find(trace_list, Div(cls=Contains("mf-profiler-row-selected")))
assert len(selected_rows) == 1, "Exactly one row should carry the selected class"
def test_i_can_render_unselected_row_has_no_selected_class(self, profiler_control):
"""Test that non-selected rows do not carry the selected CSS class.
Why these elements matter:
- cls And(Contains("mf-profiler-row"), DoesNotContain("mf-profiler-row-selected")):
Confirms that only the selected trace is highlighted, not its siblings
"""
trace_a = make_trace("CommandA", 30.0)
trace_b = make_trace("CommandB", 80.0)
profiler._traces.appendleft(trace_a)
profiler._traces.appendleft(trace_b)
profiler_control._selected_id = trace_b.trace_id
trace_list = profiler_control._mk_trace_list()
unselected_rows = find(trace_list,
Div(cls=And(Contains("mf-profiler-row"), DoesNotContain("mf-profiler-row-selected"))))
assert len(unselected_rows) == 1, "Exactly one row should remain unselected"
@pytest.mark.parametrize("duration_ms, expected_cls", [
(10.0, "mf-profiler-fast"),
(50.0, "mf-profiler-medium"),
(150.0, "mf-profiler-slow"),
])
def test_i_can_render_duration_color_class(self, profiler_control, duration_ms, expected_cls):
"""Test that the duration span carries the correct color class per threshold.
Why these elements matter:
- mf-profiler-fast/medium/slow on the duration Span: color-codes performance
at a glance, consistent with the thresholds defined in _duration_cls()
"""
trace = make_trace("TestCommand", duration_ms)
profiler._traces.appendleft(trace)
trace_list = profiler_control._mk_trace_list()
duration_spans = find(trace_list, Span(cls=Contains(expected_cls)))
assert len(duration_spans) == 1, f"Expected exactly one span with class '{expected_cls}'"

View File

@@ -41,4 +41,4 @@ def db_manager(parent):
@pytest.fixture
def dsm(parent, db_manager):
return DataServicesManager(parent, parent._session)
return DataServicesManager(parent)

678
tests/core/test_profiler.py Normal file
View File

@@ -0,0 +1,678 @@
import time
from datetime import datetime
import pytest
from myfasthtml.core.profiler import (
ProfilingManager,
ProfilingSpan,
CumulativeSpan,
ProfilingTrace,
_NullSpan,
)
@pytest.fixture(autouse=True)
def fresh_profiler():
"""Provide a fresh ProfilingManager for each test."""
p = ProfilingManager(max_traces=10)
p.enabled = True
return p
# ---------------------------------------------------------------------------
# TestProfilingModels — data model tests (ProfilingSpan, CumulativeSpan)
# ---------------------------------------------------------------------------
class TestProfilingModels:
def test_i_can_compute_avg_ms_when_no_iterations(self):
"""Test that avg_ms returns 0.0 on a fresh CumulativeSpan to avoid division by zero."""
cum = CumulativeSpan(name="empty")
assert cum.avg_ms == 0.0
assert cum.total_ms == 0.0
assert cum.count == 0
assert cum.min_ms == float('inf')
assert cum.max_ms == 0
@pytest.mark.parametrize("durations, expected_min, expected_max, expected_total, expected_avg", [
([10.0, 5.0, 8.0], 5.0, 10.0, 23.0, 23.0 / 3),
([1.0], 1.0, 1.0, 1.0, 1.0),
([3.0, 3.0, 3.0], 3.0, 3.0, 9.0, 3.0),
])
def test_i_can_aggregate_iterations_in_cumulative_span(
self, durations, expected_min, expected_max, expected_total, expected_avg
):
"""Test that CumulativeSpan correctly aggregates all metrics across iterations."""
cum = CumulativeSpan(name="probe")
for d in durations:
cum.record(d)
assert cum.count == len(durations)
assert cum.min_ms == expected_min
assert cum.max_ms == expected_max
assert cum.total_ms == expected_total
assert cum.avg_ms == pytest.approx(expected_avg)
def test_i_can_chain_set_calls_on_span(self, fresh_profiler):
"""Test that set() returns self to allow fluent chaining."""
p = fresh_profiler
with p.span("query") as s:
result = s.set("table", "orders").set("rows", 42)
assert result is s
assert s.data["table"] == "orders"
assert s.data["rows"] == 42
def test_i_can_access_span_data_after_context_exits(self, fresh_profiler):
"""Test that span data and duration persist after the with block has exited."""
p = fresh_profiler
with p.span("query") as s:
s.set("key", "value")
assert s.data["key"] == "value"
assert s.duration_ms > 0
# ---------------------------------------------------------------------------
# TestSpan — profiler.span() context manager and decorator
# ---------------------------------------------------------------------------
class TestSpan:
def test_i_can_create_a_span(self, fresh_profiler):
"""Test that a span is created with correct name and measured duration."""
p = fresh_profiler
with p.span("my_span") as s:
time.sleep(0.01)
assert isinstance(s, ProfilingSpan)
assert s.name == "my_span"
assert s.duration_ms >= 10
def test_i_can_nest_spans(self, fresh_profiler):
"""Test that a span created inside another becomes its child."""
p = fresh_profiler
with p.span("parent") as parent:
with p.span("child") as child:
pass
assert len(parent.children) == 1
assert parent.children[0] is child
assert child.name == "child"
def test_i_can_have_sibling_spans(self, fresh_profiler):
"""Test that consecutive spans under the same parent are recorded as siblings."""
p = fresh_profiler
with p.span("parent") as parent:
with p.span("child_a"):
pass
with p.span("child_b"):
pass
assert len(parent.children) == 2
assert parent.children[0].name == "child_a"
assert parent.children[1].name == "child_b"
def test_i_can_create_two_spans_with_same_name_under_same_parent(self, fresh_profiler):
"""Test that two normal spans with the same name under the same parent are two distinct entries.
This contrasts with cumulative spans, which merge same-name entries into one aggregated entry.
"""
p = fresh_profiler
with p.span("parent") as parent:
with p.span("work"):
pass
with p.span("work"):
pass
assert len(parent.children) == 2, "Normal spans with the same name must remain separate entries"
assert parent.children[0] is not parent.children[1]
def test_i_can_use_same_span_name_under_different_parents(self, fresh_profiler):
"""Test that spans with the same name under different parents are independent objects."""
p = fresh_profiler
with p.span("root"):
with p.span("parent_a") as parent_a:
with p.span("work"):
pass
with p.span("parent_b") as parent_b:
with p.span("work"):
pass
span_a = parent_a.children[0]
span_b = parent_b.children[0]
assert span_a is not span_b, "Same name under different parents must be separate objects"
def test_i_can_use_span_as_decorator(self, fresh_profiler):
"""Test that @span wraps a function and captures all positional arguments."""
p = fresh_profiler
@p.span("decorated")
def my_func(x, y):
return x + y
with p.span("root") as root:
result = my_func(1, 2)
assert result == 3
assert len(root.children) == 1
child = root.children[0]
assert child.name == "decorated"
assert child.data.get("x") == "1"
assert child.data.get("y") == "2"
def test_i_can_capture_all_args_with_span_decorator(self, fresh_profiler):
"""Test that all positional and keyword arguments are captured by the @span decorator."""
p = fresh_profiler
@p.span("compute")
def my_func(x, y, z=10):
return x + y + z
with p.span("root") as root:
my_func(1, 2, z=3)
child = root.children[0]
assert child.data.get("x") == "1"
assert child.data.get("y") == "2"
assert child.data.get("z") == "3"
def test_i_can_exclude_self_from_captured_args_with_span_decorator(self, fresh_profiler):
"""Test that 'self' is not included in the captured args of a decorated method."""
p = fresh_profiler
class MyClass:
@p.span("method")
def my_method(self, value):
return value
with p.span("root") as root:
MyClass().my_method(42)
child = root.children[0]
assert "self" not in child.data
assert child.data.get("value") == "42"
def test_i_can_use_span_decorator_without_parent(self, fresh_profiler):
"""Test that a decorated function runs correctly with no active parent span."""
p = fresh_profiler
@p.span("solo")
def my_func():
return 42
result = my_func()
assert result == 42
def test_i_can_attach_data_to_span_via_context_manager(self, fresh_profiler):
"""Test that metadata can be attached to a span inside the with block."""
p = fresh_profiler
with p.span("query") as s:
s.set("row_count", 42)
s.set("table", "users")
assert s.data["row_count"] == 42
assert s.data["table"] == "users"
def test_i_can_attach_data_via_current_span(self, fresh_profiler):
"""Test that current_span().set() attaches metadata to the active span from anywhere."""
p = fresh_profiler
@p.span("process")
def process():
p.current_span().set("result", "ok")
with p.span("root") as root:
process()
child = root.children[0]
assert child.data["result"] == "ok"
def test_i_can_pass_args_to_span_context_manager(self, fresh_profiler):
"""Test that metadata can be pre-attached to a span via the args parameter."""
p = fresh_profiler
with p.span("query", args={"table": "orders"}) as s:
pass
assert s.data["table"] == "orders"
def test_i_can_get_none_from_current_span_when_no_span_active(self, fresh_profiler):
"""Test that current_span() returns None when called outside any span context."""
p = fresh_profiler
assert p.current_span() is None
def test_i_can_get_innermost_span_via_current_span(self, fresh_profiler):
"""Test that current_span() returns the innermost active span in nested contexts."""
p = fresh_profiler
with p.span("outer"):
with p.span("inner") as inner:
assert p.current_span() is inner
# ---------------------------------------------------------------------------
# TestCumulativeSpan — profiler.cumulative_span() context manager and decorator
# ---------------------------------------------------------------------------
class TestCumulativeSpan:
def test_i_can_use_cumulative_span(self, fresh_profiler):
"""Test that repeated iterations are aggregated into a single child entry."""
p = fresh_profiler
with p.span("loop") as loop_span:
for _ in range(5):
with p.cumulative_span("item"):
time.sleep(0.001)
assert len(loop_span.children) == 1
cum = loop_span.children[0]
assert isinstance(cum, CumulativeSpan)
assert cum.count == 5
assert cum.total_ms >= 5
assert cum.min_ms <= cum.avg_ms <= cum.max_ms
def test_i_can_use_cumulative_span_as_decorator(self, fresh_profiler):
"""Test that @cumulative_span aggregates all decorated function calls."""
p = fresh_profiler
@p.cumulative_span("item")
def process_item(x):
return x * 2
with p.span("loop") as loop_span:
for i in range(3):
process_item(i)
assert len(loop_span.children) == 1
cum = loop_span.children[0]
assert cum.count == 3
def test_i_can_continue_using_the_cumulative_span(self, fresh_profiler):
"""Test that the same cumulative span accumulates across separate loop blocks."""
p = fresh_profiler
with p.span("parent") as parent:
for _ in range(3):
with p.cumulative_span("reads"):
pass
for _ in range(2):
with p.cumulative_span("reads"):
pass
assert len(parent.children) == 1
reads = parent.children[0]
assert reads.count == 5
def test_i_can_have_two_cumulative_spans_with_different_names(self, fresh_profiler):
"""Test that two cumulative spans with different names create separate entries in the parent."""
p = fresh_profiler
with p.span("parent") as parent:
for _ in range(3):
with p.cumulative_span("reads"):
pass
for _ in range(2):
with p.cumulative_span("writes"):
pass
assert len(parent.children) == 2
reads = next(c for c in parent.children if c.name == "reads")
writes = next(c for c in parent.children if c.name == "writes")
assert reads.count == 3
assert writes.count == 2
def test_i_can_use_same_cumulative_span_name_under_different_parents(self, fresh_profiler):
"""Test that cumulative spans with the same name under different parents are independent."""
p = fresh_profiler
with p.span("root"):
with p.span("parent_a") as parent_a:
for _ in range(3):
with p.cumulative_span("items"):
pass
with p.span("parent_b") as parent_b:
for _ in range(2):
with p.cumulative_span("items"):
pass
items_a = parent_a.children[0]
items_b = parent_b.children[0]
assert items_a is not items_b, "Same name under different parents must be separate objects"
assert items_a.count == 3
assert items_b.count == 2
# ---------------------------------------------------------------------------
# TestCommandSpan — profiler.command_span() and trace recording
# ---------------------------------------------------------------------------
class TestCommandSpan:
def test_i_can_record_a_trace_via_command_span(self, fresh_profiler):
"""Test that command_span creates a complete trace with root span and children."""
p = fresh_profiler
with p.command_span("NavigateCell", "", "abc-123", {"row": "5"}):
with p.span("callback"):
time.sleep(0.01)
assert len(p.traces) == 1
trace = p.traces[0]
assert isinstance(trace, ProfilingTrace)
assert trace.command_name == "NavigateCell"
assert trace.command_id == "abc-123"
assert trace.kwargs == {"row": "5"}
assert trace.total_duration_ms >= 10
assert len(trace.root_span.children) == 1
assert trace.root_span.children[0].name == "callback"
def test_i_cannot_record_trace_when_profiler_disabled(self, fresh_profiler):
"""Test that command_span is a no-op when the profiler is disabled."""
p = fresh_profiler
p.enabled = False
with p.command_span("cmd", "", "id", {}):
pass
assert len(p.traces) == 0
def test_i_can_record_up_to_max_traces(self, fresh_profiler):
"""Test that the trace buffer respects the max_traces limit (FIFO eviction)."""
p = fresh_profiler
for i in range(15):
with p.command_span(f"cmd_{i}", "", str(i), {}):
pass
assert len(p.traces) == 10, "Buffer should cap at max_traces=10"
def test_i_can_access_trace_timestamp(self, fresh_profiler):
"""Test that a recorded trace contains a valid datetime timestamp."""
p = fresh_profiler
before = datetime.now()
with p.command_span("MyCmd", "", "id-001", {}):
pass
after = datetime.now()
trace = p.traces[0]
assert before <= trace.timestamp <= after
def test_i_can_verify_kwargs_are_copied_in_command_span(self, fresh_profiler):
"""Test that mutating the original kwargs dict does not affect the recorded trace."""
p = fresh_profiler
kwargs = {"row": "5", "col": "2"}
with p.command_span("MyCmd", "", "id-001", kwargs):
pass
kwargs["row"] = "99"
trace = p.traces[0]
assert trace.kwargs["row"] == "5"
def test_i_can_record_command_description_in_trace(self, fresh_profiler):
"""Test that the command description passed to command_span is stored in the trace."""
p = fresh_profiler
with p.command_span("NavigateCell", "Navigate to adjacent cell", "abc-123", {}):
pass
trace = p.traces[0]
assert trace.command_description == "Navigate to adjacent cell"
def test_i_can_record_empty_description_in_trace(self, fresh_profiler):
"""Test that an empty description is stored as-is in the trace."""
p = fresh_profiler
with p.command_span("MyCmd", "", "id-001", {}):
pass
trace = p.traces[0]
assert trace.command_description == ""
# ---------------------------------------------------------------------------
# TestTraceAll — profiler.trace_all() class decorator
# ---------------------------------------------------------------------------
class TestTraceAll:
def test_i_can_use_trace_all_on_class(self, fresh_profiler):
"""Test that trace_all wraps all non-dunder methods of a class."""
p = fresh_profiler
@p.trace_all
class MyClass:
def method_a(self):
return "a"
def method_b(self):
return "b"
def __repr__(self):
return "MyClass()"
obj = MyClass()
with p.span("root") as root:
obj.method_a()
obj.method_b()
assert len(root.children) == 2
assert root.children[0].name == "method_a"
assert root.children[1].name == "method_b"
def test_i_can_use_trace_all_with_exclude(self, fresh_profiler):
"""Test that excluded methods are not wrapped by trace_all."""
p = fresh_profiler
@p.trace_all(exclude=["method_b"])
class MyClass:
def method_a(self):
return "a"
def method_b(self):
return "b"
obj = MyClass()
with p.span("root") as root:
obj.method_a()
obj.method_b()
assert len(root.children) == 1
assert root.children[0].name == "method_a"
def test_i_can_confirm_trace_all_skips_dunder_methods(self, fresh_profiler):
"""Test that trace_all does not wrap dunder methods like __repr__."""
p = fresh_profiler
call_log = []
@p.trace_all
class MyClass:
def __repr__(self):
call_log.append("repr")
return "MyClass()"
def method_a(self):
return "a"
obj = MyClass()
with p.span("root") as root:
repr(obj)
obj.method_a()
child_names = [c.name for c in root.children]
assert "method_a" in child_names
assert "__repr__" not in child_names
assert "repr" in call_log
def test_i_can_use_trace_all_with_parentheses_and_no_exclude(self, fresh_profiler):
"""Test that @profiler.trace_all() with parentheses and no args behaves like @profiler.trace_all."""
p = fresh_profiler
@p.trace_all()
class MyClass:
def method_a(self):
return "a"
obj = MyClass()
with p.span("root") as root:
obj.method_a()
assert len(root.children) == 1
assert root.children[0].name == "method_a"
# ---------------------------------------------------------------------------
# TestTraceCalls — profiler.trace_calls() function decorator
# ---------------------------------------------------------------------------
class TestTraceCalls:
def test_i_can_use_trace_calls_on_function(self, fresh_profiler):
"""Test that trace_calls traces all sub-calls as children of the decorated function span.
Verifies both direct children (helper_a, helper_b under main_func) and nested hierarchy
(grandchild under helper_a, not under main_func).
"""
p = fresh_profiler
def grandchild():
return 0
def helper_a():
grandchild()
return 1
def helper_b():
return 2
@p.trace_calls
def main_func():
helper_a()
helper_b()
return 42
with p.span("root") as root:
main_func()
assert len(root.children) == 1
main_span = root.children[0]
assert main_span.name == "main_func"
assert len(main_span.children) == 2, "main_func should have exactly 2 direct children"
child_names = [c.name for c in main_span.children]
assert "helper_a" in child_names
assert "helper_b" in child_names
helper_a_span = next(c for c in main_span.children if c.name == "helper_a")
assert len(helper_a_span.children) == 1, "grandchild must be nested under helper_a, not main_func"
assert helper_a_span.children[0].name == "grandchild"
def test_i_cannot_use_trace_calls_when_disabled(self, fresh_profiler):
"""Test that trace_calls creates no spans when the profiler is disabled at call time."""
p = fresh_profiler
@p.trace_calls
def main_func():
return 99
with p.span("root") as root:
p.enabled = False
main_func()
p.enabled = True
assert len(root.children) == 0, "trace_calls should not create spans when profiler is disabled"
def test_i_can_verify_trace_calls_captures_function_args(self, fresh_profiler):
"""Test that trace_calls captures the decorated function's arguments in the root span data."""
p = fresh_profiler
@p.trace_calls
def compute(x, y):
return x + y
with p.span("root") as root:
compute(3, 7)
main_span = root.children[0]
assert main_span.name == "compute"
assert main_span.data.get("x") == "3"
assert main_span.data.get("y") == "7"
# ---------------------------------------------------------------------------
# TestProfilingManager — enable/disable, clear, overhead
# ---------------------------------------------------------------------------
class TestProfilingManager:
def test_i_can_enable_disable_profiler(self, fresh_profiler):
"""Test that disabling the profiler makes span() return a no-op NullSpan."""
p = fresh_profiler
p.enabled = False
with p.span("ignored") as s:
pass
assert isinstance(s, _NullSpan)
def test_i_can_use_decorator_when_profiler_is_disabled(self, fresh_profiler):
"""Test that a @span decorated function still executes correctly when profiler is disabled."""
p = fresh_profiler
@p.span("my_span")
def my_func():
return "result"
p.enabled = False
result = my_func()
assert result == "result"
def test_i_can_toggle_profiler_at_runtime(self, fresh_profiler):
"""Test that spans are captured only while the profiler is enabled.
Three phases: enabled -> disabled -> re-enabled, verifying capture behavior at each step.
"""
p = fresh_profiler
@p.span("traced")
def my_func():
return 1
# Phase 1: enabled — span must be captured
with p.span("root") as root:
my_func()
assert len(root.children) == 1, "Span should be captured when profiler is enabled"
# Phase 2: disabled — p.span() returns NullSpan, nothing captured
p.enabled = False
with p.span("root_disabled") as root_disabled:
my_func()
assert isinstance(root_disabled, _NullSpan), "p.span() should return NullSpan when disabled"
# Phase 3: re-enabled — span must be captured again
p.enabled = True
with p.span("root_reenabled") as root_reenabled:
my_func()
assert len(root_reenabled.children) == 1, "Span should be captured again after re-enabling"
def test_i_can_clear_traces(self, fresh_profiler):
"""Test that clear() empties the trace buffer completely."""
p = fresh_profiler
with p.command_span("cmd", "", "uuid-1", {}):
pass
with p.command_span("cmd", "", "uuid-2", {}):
pass
assert len(p.traces) == 2
p.clear()
assert len(p.traces) == 0
def test_i_can_measure_overhead(self, fresh_profiler):
"""Test that overhead metrics are populated after spans are recorded."""
p = fresh_profiler
for _ in range(20):
with p.span("probe"):
pass
assert p.overhead_per_span_us >= 0
assert p.total_overhead_ms >= 0
def test_i_can_get_zero_overhead_when_no_samples(self, fresh_profiler):
"""Test that overhead_per_span_us returns 0.0 when no spans have been recorded."""
p = fresh_profiler
assert p.overhead_per_span_us == 0.0
def test_i_can_get_zero_total_overhead_when_buffer_empty(self, fresh_profiler):
"""Test that total_overhead_ms returns 0.0 when the trace buffer is empty."""
p = fresh_profiler
assert p.total_overhead_ms == 0.0