Added Profiler control with basic UI

This commit is contained in:
2026-03-22 08:32:40 +01:00
parent 72d6cce6ff
commit b8fd4e5ed1
14 changed files with 3440 additions and 1105 deletions

View File

@@ -7,6 +7,23 @@ per command call). The HTMX debug traces (via `htmx_debug.js`) confirmed the bot
server-side. A persistent, in-application profiling system is needed for continuous analysis server-side. A persistent, in-application profiling system is needed for continuous analysis
across sessions and future investigations. 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 ## Design Decisions
### Data Collection Strategy ### Data Collection Strategy
@@ -150,7 +167,7 @@ of the first command's active span.
The `ProfilingManager` self-profiles its own `span.__enter__` and `span.__exit__` calls. The `ProfilingManager` self-profiles its own `span.__enter__` and `span.__exit__` calls.
Exposes: Exposes:
- `overhead_per_span_ns` — average cost of one span boundary in nanoseconds - `overhead_per_span_us` — average cost of one span boundary in microseconds
- `total_overhead_ms` — estimated total overhead across all active spans - `total_overhead_ms` — estimated total overhead across all active spans
Visible in the UI to verify the profiler does not bias measurements significantly. Visible in the UI to verify the profiler does not bias measurements significantly.
@@ -186,19 +203,20 @@ CumulativeSpan
--- ---
## Existing Code Hooks ## Code Hooks
### `src/myfasthtml/core/utils.py` — route handler (Level A) ### `src/myfasthtml/core/utils.py` — route handler (Level A)
```python ```python
@utils_rt(Routes.Commands) command = CommandsManager.get_command(c_id)
async def post(session, c_id: str, client_response: dict = None): if command:
with profiler.span("command", args={"c_id": c_id}): with profiler.command_span(command.name, c_id, client_response or {}):
command = CommandsManager.get_command(c_id) return command.execute(client_response)
return await command.execute(client_response)
``` ```
### `src/myfasthtml/core/commands.py` — execution phases (Level B) ### `src/myfasthtml/core/commands.py` — execution phases (Level B) ⏳ Deferred
Planned breakdown inside `Command.execute()`:
```python ```python
def execute(self, client_response=None): def execute(self, client_response=None):
@@ -212,54 +230,120 @@ def execute(self, client_response=None):
... ...
``` ```
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 ## Implementation Plan
### Phase 1 — Core ### Phase 1 — Core ✅ Complete
**File**: `src/myfasthtml/core/profiler.py` 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
1. `ProfilingSpan` dataclass **Tests**: `tests/core/test_profiler.py` — 7 classes, full coverage ✅
2. `CumulativeSpan` dataclass
3. `ProfilingTrace` dataclass
4. `ProfilingManager` class with all probe mechanisms
5. `profiler` singleton
6. Hook into `utils.py` (Level A)
7. Hook into `commands.py` (Level B)
**Tests**: `tests/core/test_profiler.py`
| Test | Description |
|------|-------------|
| `test_i_can_create_a_span` | Basic span creation and timing |
| `test_i_can_nest_spans` | Child spans are correctly parented |
| `test_i_can_use_span_as_decorator` | Decorator captures args automatically |
| `test_i_can_use_cumulative_span` | Aggregates count/total/min/max/avg |
| `test_i_can_attach_data_to_span` | `span.set()` and `current_span().set()` |
| `test_i_can_clear_traces` | Buffer is emptied after `clear()` |
| `test_i_can_enable_disable_profiler` | Probes are no-ops when disabled |
| `test_i_can_measure_overhead` | Overhead metrics are exposed |
| `test_i_can_use_trace_all_on_class` | All methods of a class are wrapped |
| `test_i_can_use_trace_calls_on_function` | Sub-calls are traced via setprofile |
### Phase 2 — Controls ### Phase 2 — Controls
**`src/myfasthtml/controls/ProfilerList.py`** (SingleInstance) #### Step 2.1 — Global layout (current) 🔄
- Table of all traces: command name / total duration / timestamp
- Right panel: trace detail (kwargs, span breakdown)
- Buttons: enable/disable, clear
- Click on a trace → opens ProfilerDetail
**`src/myfasthtml/controls/ProfilerDetail.py`** (MultipleInstance) `src/myfasthtml/controls/Profiler.py`:
- Hierarchical span tree for a single trace - `SingleInstance` inheriting
- Two display modes: list and pie chart - Toolbar: `mk.icon()` for enable/disable and clear, overhead text
- Click on a span → zooms into its children (if any) - `Panel` for split layout
- Displays cumulative spans with count/min/max/avg - Left: trace list table (command / duration / timestamp), click → select_trace command
- Shows overhead metrics - Right: placeholder (empty until Step 2.2)
**`src/myfasthtml/controls/ProfilerPieChart.py`** (future) `src/myfasthtml/assets/core/profiler.css`:
- Pie chart visualization of span distribution at a given zoom level - 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`
--- ---

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

View File

@@ -0,0 +1,177 @@
/* ================================================================== */
/* 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);
}
/* ------------------------------------------------------------------
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

@@ -272,7 +272,7 @@ class DataGrid(MultipleInstance):
self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter()) self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter())
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query() self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
# add Selection Selector # add Selection Selector (cell, row, column)
selection_types = { selection_types = {
"cell": mk.icon(grid, tooltip="Cell selection"), # default "cell": mk.icon(grid, tooltip="Cell selection"), # default
"row": mk.icon(row, tooltip="Row selection"), "row": mk.icon(row, tooltip="Row selection"),

View File

@@ -1,13 +1,14 @@
from fastcore.basics import NotStr 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.core.utils import pascal_to_snake
from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \ from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \
number_row20_regular number_row20_regular
from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \ from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \
checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_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 from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular, play_circle20_regular, \
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular dismiss_circle20_regular
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular, record_stop20_regular
default_icons = { default_icons = {
# default type icons # default type icons
@@ -22,6 +23,12 @@ default_icons = {
"TreeViewFolder": folder20_regular, "TreeViewFolder": folder20_regular,
"TreeViewFile": document20_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 # Datagrid column icons
ColumnType.RowIndex: number_symbol20_regular, ColumnType.RowIndex: number_symbol20_regular,
ColumnType.Text: text_field20_regular, ColumnType.Text: text_field20_regular,

View File

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

View File

@@ -0,0 +1,202 @@
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.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.profiler import profiler
from myfasthtml.icons.fluent import arrow_clockwise20_regular
logger = logging.getLogger("Profiler")
class Commands(BaseCommands):
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"#{self._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._selected_id: str | None = None
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."""
self._selected_id = trace_id
return self
def handle_refresh(self):
"""Select a trace row and re-render to show it highlighted."""
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_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 = []
for trace in traces:
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
duration_cls = self._duration_cls(trace.total_duration_ms)
row_cls = "mf-profiler-row mf-profiler-row-selected" if trace.trace_id == self._selected_id else "mf-profiler-row"
row = 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,
),
command=self.commands.select_trace(trace.trace_id),
)
rows.append(row)
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")
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self):
self._panel.set_main(self._mk_trace_list())
self._panel.set_right(self._mk_detail_placeholder())
return Div(
self._mk_toolbar(),
self._panel,
id=self._id,
cls="mf-profiler",
)
def __ft__(self):
return self.render()

View File

@@ -33,6 +33,13 @@ class ColumnType(Enum):
Formula = "Formula" Formula = "Formula"
class MediaActions(Enum):
Play = "Play"
Pause = "Pause"
Stop = "Stop"
Cancel = "Cancel"
def get_columns_types() -> list[ColumnType]: def get_columns_types() -> list[ColumnType]:
return [c for c in ColumnType if not c.value.endswith("_")] return [c for c in ColumnType if not c.value.endswith("_")]

View File

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

View File

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

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")))
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}'"

File diff suppressed because it is too large Load Diff