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)
async def post(session, c_id: str, client_response: dict = None):
with profiler.span("command", args={"c_id": c_id}):
command = CommandsManager.get_command(c_id) command = CommandsManager.get_command(c_id)
return await command.execute(client_response) if command:
with profiler.command_span(command.name, c_id, client_response or {}):
return command.execute(client_response)
``` ```
### `src/myfasthtml/core/commands.py` — execution phases (Level B) ### `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")
@@ -114,6 +115,7 @@ class ProfilingTrace:
Attributes: Attributes:
command_name: Name of the executed command. command_name: Name of the executed command.
command_description: Human-readable description of the command.
command_id: UUID of the command. command_id: UUID of the command.
kwargs: Arguments received from the client. kwargs: Arguments received from the client.
timestamp: When the command was received. timestamp: When the command was received.
@@ -122,11 +124,13 @@ class ProfilingTrace:
""" """
command_name: str command_name: str
command_description: str
command_id: str command_id: str
kwargs: dict kwargs: dict
timestamp: datetime timestamp: datetime
root_span: Optional[ProfilingSpan] = None root_span: Optional[ProfilingSpan] = None
total_duration_ms: float = 0.0 total_duration_ms: float = 0.0
trace_id: str = field(default_factory=lambda: str(uuid4()))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -236,10 +240,15 @@ class _CommandSpan:
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',
command_name: str,
command_description: str,
command_id: str,
kwargs: dict):
self._manager = manager self._manager = manager
self._command_name = command_name self._command_name = command_name
self._command_description = command_description
self._command_id = command_id self._command_id = command_id
self._kwargs = kwargs self._kwargs = kwargs
self._trace: Optional[ProfilingTrace] = None self._trace: Optional[ProfilingTrace] = None
@@ -249,6 +258,7 @@ class _CommandSpan:
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_description=self._command_description,
command_id=self._command_id, command_id=self._command_id,
kwargs=dict(self._kwargs) if self._kwargs else {}, kwargs=dict(self._kwargs) if self._kwargs else {},
timestamp=datetime.now(), timestamp=datetime.now(),
@@ -403,7 +413,10 @@ class ProfilingManager:
""" """
return _CumulativeActiveSpan(self, name) return _CumulativeActiveSpan(self, name)
def command_span(self, command_name: str, command_id: str, def command_span(self,
command_name: str,
command_description: str,
command_id: str,
kwargs: dict) -> '_CommandSpan | _NullSpan': kwargs: dict) -> '_CommandSpan | _NullSpan':
"""Context manager for the route handler. """Context manager for the route handler.
@@ -412,6 +425,7 @@ class ProfilingManager:
Args: Args:
command_name: Human-readable command name. command_name: Human-readable command name.
command_description: Human-readable description of the command.
command_id: UUID string of the command. command_id: UUID string of the command.
kwargs: Client arguments received by the route handler. kwargs: Client arguments received by the route handler.
@@ -420,7 +434,7 @@ class ProfilingManager:
""" """
if not self.enabled: if not self.enabled:
return _NullSpan() return _NullSpan()
return _CommandSpan(self, command_name, command_id, kwargs) return _CommandSpan(self, command_name, command_description, command_id, kwargs)
def trace_all(self, cls=None, *, exclude: list = None): def trace_all(self, cls=None, *, exclude: list = None):
"""Class decorator — statically wraps all non-dunder methods with spans. """Class decorator — statically wraps all non-dunder methods with spans.

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,6 +380,7 @@ 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}.")
with profiler.command_span(command.name, command.description, c_id, client_response or {}):
return command.execute(client_response) 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}'"

View File

@@ -348,7 +348,7 @@ class TestCommandSpan:
def test_i_can_record_a_trace_via_command_span(self, fresh_profiler): def test_i_can_record_a_trace_via_command_span(self, fresh_profiler):
"""Test that command_span creates a complete trace with root span and children.""" """Test that command_span creates a complete trace with root span and children."""
p = fresh_profiler p = fresh_profiler
with p.command_span("NavigateCell", "abc-123", {"row": "5"}): with p.command_span("NavigateCell", "", "abc-123", {"row": "5"}):
with p.span("callback"): with p.span("callback"):
time.sleep(0.01) time.sleep(0.01)
@@ -366,7 +366,7 @@ class TestCommandSpan:
"""Test that command_span is a no-op when the profiler is disabled.""" """Test that command_span is a no-op when the profiler is disabled."""
p = fresh_profiler p = fresh_profiler
p.enabled = False p.enabled = False
with p.command_span("cmd", "id", {}): with p.command_span("cmd", "", "id", {}):
pass pass
assert len(p.traces) == 0 assert len(p.traces) == 0
@@ -375,7 +375,7 @@ class TestCommandSpan:
"""Test that the trace buffer respects the max_traces limit (FIFO eviction).""" """Test that the trace buffer respects the max_traces limit (FIFO eviction)."""
p = fresh_profiler p = fresh_profiler
for i in range(15): for i in range(15):
with p.command_span(f"cmd_{i}", str(i), {}): with p.command_span(f"cmd_{i}", "", str(i), {}):
pass pass
assert len(p.traces) == 10, "Buffer should cap at max_traces=10" assert len(p.traces) == 10, "Buffer should cap at max_traces=10"
@@ -384,7 +384,7 @@ class TestCommandSpan:
"""Test that a recorded trace contains a valid datetime timestamp.""" """Test that a recorded trace contains a valid datetime timestamp."""
p = fresh_profiler p = fresh_profiler
before = datetime.now() before = datetime.now()
with p.command_span("MyCmd", "id-001", {}): with p.command_span("MyCmd", "", "id-001", {}):
pass pass
after = datetime.now() after = datetime.now()
@@ -395,13 +395,31 @@ class TestCommandSpan:
"""Test that mutating the original kwargs dict does not affect the recorded trace.""" """Test that mutating the original kwargs dict does not affect the recorded trace."""
p = fresh_profiler p = fresh_profiler
kwargs = {"row": "5", "col": "2"} kwargs = {"row": "5", "col": "2"}
with p.command_span("MyCmd", "id-001", kwargs): with p.command_span("MyCmd", "", "id-001", kwargs):
pass pass
kwargs["row"] = "99" kwargs["row"] = "99"
trace = p.traces[0] trace = p.traces[0]
assert trace.kwargs["row"] == "5" assert trace.kwargs["row"] == "5"
def test_i_can_record_command_description_in_trace(self, fresh_profiler):
"""Test that the command description passed to command_span is stored in the trace."""
p = fresh_profiler
with p.command_span("NavigateCell", "Navigate to adjacent cell", "abc-123", {}):
pass
trace = p.traces[0]
assert trace.command_description == "Navigate to adjacent cell"
def test_i_can_record_empty_description_in_trace(self, fresh_profiler):
"""Test that an empty description is stored as-is in the trace."""
p = fresh_profiler
with p.command_span("MyCmd", "", "id-001", {}):
pass
trace = p.traces[0]
assert trace.command_description == ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# TestTraceAll — profiler.trace_all() class decorator # TestTraceAll — profiler.trace_all() class decorator
@@ -630,9 +648,9 @@ class TestProfilingManager:
def test_i_can_clear_traces(self, fresh_profiler): def test_i_can_clear_traces(self, fresh_profiler):
"""Test that clear() empties the trace buffer completely.""" """Test that clear() empties the trace buffer completely."""
p = fresh_profiler p = fresh_profiler
with p.command_span("cmd", "uuid-1", {}): with p.command_span("cmd", "", "uuid-1", {}):
pass pass
with p.command_span("cmd", "uuid-2", {}): with p.command_span("cmd", "", "uuid-2", {}):
pass pass
assert len(p.traces) == 2 assert len(p.traces) == 2