Compare commits
8 Commits
853bc4abae
...
WorkingOnD
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ea551bc1a | |||
| 3bcf50f55f | |||
| 7f099b14f6 | |||
| 0e1087a614 | |||
| d3c0381e34 | |||
| b8fd4e5ed1 | |||
| 72d6cce6ff | |||
| f887267362 |
355
docs/Profiler.md
Normal file
355
docs/Profiler.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Profiler — Design & Implementation Plan
|
||||
|
||||
## Context
|
||||
|
||||
Performance issues were identified during keyboard navigation in the DataGrid (173ms server-side
|
||||
per command call). The HTMX debug traces (via `htmx_debug.js`) confirmed the bottleneck is
|
||||
server-side. A persistent, in-application profiling system is needed for continuous analysis
|
||||
across sessions and future investigations.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Phase | Item | Status |
|
||||
|-------|------|--------|
|
||||
| **Phase 1 — Core** | `profiler.py` — data model + probe mechanisms | ✅ Done |
|
||||
| **Phase 1 — Core** | `tests/core/test_profiler.py` — full test suite (7 classes) | ✅ Done |
|
||||
| **Phase 1 — Core** | Hook `utils.py` — Level A `command_span` | ✅ Done |
|
||||
| **Phase 1 — Core** | Hook `commands.py` — Level B phases | ⏳ Deferred |
|
||||
| **Phase 2 — Controls** | `Profiler.py` — global layout (toolbar + list) | 🔄 In progress |
|
||||
| **Phase 2 — Controls** | `Profiler.py` — detail panel (span tree + pie) | ⏳ Pending |
|
||||
| **Phase 2 — Controls** | CSS `profiler.css` | 🔄 In progress |
|
||||
| **Phase 2 — Controls** | `ProfilerPieChart.py` | ⏳ Future |
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Data Collection Strategy
|
||||
|
||||
Two complementary levels:
|
||||
|
||||
- **Level A** (route handler): One trace per `/myfasthtml/commands` call. Captures total
|
||||
server-side duration including lookup, execution, and HTMX swap overhead.
|
||||
- **Level B** (granular spans): Decomposition of each trace into named phases. Activated
|
||||
by placing probes in the code.
|
||||
|
||||
Both levels are active simultaneously. Level A gives the global picture; Level B gives the
|
||||
breakdown.
|
||||
|
||||
### Probe Mechanisms
|
||||
|
||||
Four complementary mechanisms, chosen based on the context:
|
||||
|
||||
#### 1. Context manager — partial block instrumentation
|
||||
|
||||
```python
|
||||
with profiler.span("oob_swap"):
|
||||
# only this block is timed
|
||||
result = build_oob_elements(...)
|
||||
```
|
||||
|
||||
Metadata can be attached during execution:
|
||||
|
||||
```python
|
||||
with profiler.span("query") as span:
|
||||
rows = db.query(...)
|
||||
span.set("row_count", len(rows))
|
||||
```
|
||||
|
||||
#### 2. Decorator — full function instrumentation
|
||||
|
||||
```python
|
||||
@profiler.span("callback")
|
||||
def execute_callback(self, client_response):
|
||||
...
|
||||
```
|
||||
|
||||
Function arguments are captured automatically. Metadata can be attached via `current_span()`:
|
||||
|
||||
```python
|
||||
@profiler.span("process")
|
||||
def process(self, rows):
|
||||
result = do_work(rows)
|
||||
profiler.current_span().set("row_count", len(result))
|
||||
return result
|
||||
```
|
||||
|
||||
#### 3. Cumulative span — loop instrumentation
|
||||
|
||||
For loops with many iterations. Aggregates instead of creating one span per iteration.
|
||||
|
||||
```python
|
||||
for row in rows:
|
||||
with profiler.cumulative_span("process_row"):
|
||||
process(row)
|
||||
|
||||
# or as a decorator
|
||||
@profiler.cumulative_span("process_row")
|
||||
def process_row(self, row):
|
||||
...
|
||||
```
|
||||
|
||||
Exposes: `count`, `total`, `min`, `max`, `avg`. Single entry in the trace tree regardless of
|
||||
iteration count.
|
||||
|
||||
#### 4. `trace_all` — class-level static instrumentation
|
||||
|
||||
Wraps all methods of a class at definition time. No runtime overhead beyond the spans themselves.
|
||||
|
||||
```python
|
||||
@profiler.trace_all
|
||||
class DataGrid(MultipleInstance):
|
||||
def navigate_cell(self, ...): # auto-spanned
|
||||
...
|
||||
|
||||
# Exclude specific methods
|
||||
@profiler.trace_all(exclude=["__ft__", "render"])
|
||||
class DataGrid(MultipleInstance):
|
||||
...
|
||||
```
|
||||
|
||||
Implementation: uses `inspect` to iterate over methods and wraps each with `@profiler.span()`.
|
||||
No `sys.settrace()` involved — pure static wrapping.
|
||||
|
||||
#### 5. `trace_calls` — sub-call exploration
|
||||
|
||||
Traces all function calls made within a single function, recursively. Used for exploration
|
||||
when the bottleneck location is unknown.
|
||||
|
||||
```python
|
||||
@profiler.trace_calls
|
||||
def navigate_cell(self, ...):
|
||||
self._update_selection() # auto-traced as child span
|
||||
self._compute_visible() # auto-traced as child span
|
||||
db.query(...) # auto-traced as child span
|
||||
```
|
||||
|
||||
Implementation: uses `sys.setprofile()` scoped to the decorated function's execution only.
|
||||
Overhead is localized to that function's call stack. This is an exploration tool — use it
|
||||
to identify hotspots, then replace with explicit probes.
|
||||
|
||||
### Span Hierarchy
|
||||
|
||||
Hierarchy is determined by code nesting via a `ContextVar` stack (async-safe). No explicit
|
||||
parent references required.
|
||||
|
||||
```python
|
||||
with profiler.span("execute"): # root
|
||||
with profiler.span("callback"): # child of execute
|
||||
result = self.callback(...)
|
||||
with profiler.span("oob_swap"): # sibling of callback
|
||||
...
|
||||
```
|
||||
|
||||
When a command calls another command, the second command's spans automatically become children
|
||||
of the first command's active span.
|
||||
|
||||
`profiler.current_span()` provides access to the active span from anywhere in the call stack.
|
||||
|
||||
### Storage
|
||||
|
||||
- **Scope**: Global (all sessions). Profiling measures server behavior, not per-user state.
|
||||
- **Structure**: `deque` with a configurable maximum size.
|
||||
- **Default size**: 500 traces (constant `PROFILER_MAX_TRACES`).
|
||||
- **Eviction**: Oldest traces are dropped when the buffer is full (FIFO).
|
||||
- **Persistence**: In-memory only. Lost on server restart.
|
||||
|
||||
### Toggle and Clear
|
||||
|
||||
- `profiler.enabled` — boolean flag. When `False`, all probe mechanisms are no-ops (zero overhead).
|
||||
- `profiler.clear()` — empties the trace buffer.
|
||||
- Both are controllable from the UI control.
|
||||
|
||||
### Overhead Measurement
|
||||
|
||||
The `ProfilingManager` self-profiles its own `span.__enter__` and `span.__exit__` calls.
|
||||
Exposes:
|
||||
|
||||
- `overhead_per_span_us` — average cost of one span boundary in microseconds
|
||||
- `total_overhead_ms` — estimated total overhead across all active spans
|
||||
|
||||
Visible in the UI to verify the profiler does not bias measurements significantly.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
```
|
||||
ProfilingTrace
|
||||
command_name: str
|
||||
command_id: str
|
||||
kwargs: dict
|
||||
timestamp: datetime
|
||||
total_duration_ms: float
|
||||
root_span: ProfilingSpan
|
||||
|
||||
ProfilingSpan
|
||||
name: str
|
||||
start: float (perf_counter)
|
||||
duration_ms: float
|
||||
data: dict (attached via span.set())
|
||||
children: list[ProfilingSpan | CumulativeSpan]
|
||||
|
||||
CumulativeSpan
|
||||
name: str
|
||||
count: int
|
||||
total_ms: float
|
||||
min_ms: float
|
||||
max_ms: float
|
||||
avg_ms: float
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Hooks
|
||||
|
||||
### `src/myfasthtml/core/utils.py` — route handler (Level A) ✅
|
||||
|
||||
```python
|
||||
command = CommandsManager.get_command(c_id)
|
||||
if command:
|
||||
with profiler.command_span(command.name, c_id, client_response or {}):
|
||||
return command.execute(client_response)
|
||||
```
|
||||
|
||||
### `src/myfasthtml/core/commands.py` — execution phases (Level B) ⏳ Deferred
|
||||
|
||||
Planned breakdown inside `Command.execute()`:
|
||||
|
||||
```python
|
||||
def execute(self, client_response=None):
|
||||
with profiler.span("before_commands"):
|
||||
...
|
||||
with profiler.span("callback"):
|
||||
result = self.callback(...)
|
||||
with profiler.span("after_commands"):
|
||||
...
|
||||
with profiler.span("oob_swap"):
|
||||
...
|
||||
```
|
||||
|
||||
Deferred: will be added once the UI control is functional to immediately observe the breakdown.
|
||||
|
||||
---
|
||||
|
||||
## UI Control Design
|
||||
|
||||
### Control name: `Profiler` (SingleInstance)
|
||||
|
||||
Single entry point. Replaces the earlier `ProfilerList` name.
|
||||
|
||||
**Files:**
|
||||
- `src/myfasthtml/controls/Profiler.py`
|
||||
- `src/myfasthtml/assets/core/profiler.css`
|
||||
|
||||
### Layout
|
||||
|
||||
Split view using `Panel`:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [●] [🗑] Overhead/span: 1.2µs Traces: 8/500│ ← toolbar (icon-only)
|
||||
├──────────────────────┬──────────────────────────────┤
|
||||
│ Command Duration Time│ NavigateCell — 173.4ms [≡][◕]│
|
||||
│ ──────────────────────│ ─────────────────────────────│
|
||||
│ NavigateCell 173ms … │ [Metadata card] │
|
||||
│ NavigateCell 168ms … │ [kwargs card] │
|
||||
│ SelectRow 42ms … │ [Span breakdown / Pie chart] │
|
||||
│ … │ │
|
||||
└──────────────────────┴──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Toolbar
|
||||
|
||||
Icon-only buttons, no `Menu` control (Menu does not support toggle state).
|
||||
Direct `mk.icon()` calls:
|
||||
|
||||
- **Enable/disable**: icon changes between "recording" and "stopped" states based on `profiler.enabled`
|
||||
- **Clear**: delete icon, always red
|
||||
- **Refresh**: manual refresh of the trace list (no auto-refresh yet — added in Step 2.1)
|
||||
|
||||
Overhead metrics displayed as plain text on the right side of the toolbar.
|
||||
|
||||
### Trace list (left panel)
|
||||
|
||||
Three columns: command name / duration (color-coded) / timestamp.
|
||||
Click on a row → update right panel via HTMX.
|
||||
|
||||
**Duration color thresholds:**
|
||||
- Green (`mf-profiler-fast`): < 20 ms
|
||||
- Orange (`mf-profiler-medium`): 20–100 ms
|
||||
- Red (`mf-profiler-slow`): > 100 ms
|
||||
|
||||
### Detail panel (right)
|
||||
|
||||
Two view modes, toggled by icons in the detail panel header:
|
||||
|
||||
1. **Tree view** (default): Properties-style cards (Metadata, kwargs) + span breakdown with
|
||||
proportional bars and indentation. Cumulative spans show `×N · min/avg/max` badge.
|
||||
2. **Pie view**: `ProfilerPieChart` control (future) — distribution of time across spans
|
||||
at the current zoom level.
|
||||
|
||||
The `Properties` control is used as-is for Metadata and kwargs cards.
|
||||
The span breakdown is custom rendering (not a `Properties` instance).
|
||||
|
||||
### Font conventions
|
||||
|
||||
- Labels, headings, command names: `--font-sans` (DaisyUI default)
|
||||
- Values (durations, timestamps, kwargs values): `--font-mono`
|
||||
- Consistent with `properties.css` (`mf-properties-value` uses `--default-mono-font-family`)
|
||||
|
||||
### Visual reference
|
||||
|
||||
Mockups available in `examples/`:
|
||||
- `profiler_mockup.html` — first iteration (monospace font everywhere)
|
||||
- `profiler_mockup_2.html` — **reference** (correct fonts, icon toolbar, tree/pie toggle)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1 — Core ✅ Complete
|
||||
|
||||
1. `ProfilingSpan`, `CumulativeSpan`, `ProfilingTrace` dataclasses
|
||||
2. `ProfilingManager` with all probe mechanisms
|
||||
3. `profiler` singleton
|
||||
4. Hook into `utils.py` (Level A) ✅
|
||||
5. Hook into `commands.py` (Level B) — deferred
|
||||
|
||||
**Tests**: `tests/core/test_profiler.py` — 7 classes, full coverage ✅
|
||||
|
||||
### Phase 2 — Controls
|
||||
|
||||
#### Step 2.1 — Global layout (current) 🔄
|
||||
|
||||
`src/myfasthtml/controls/Profiler.py`:
|
||||
- `SingleInstance` inheriting
|
||||
- Toolbar: `mk.icon()` for enable/disable and clear, overhead text
|
||||
- `Panel` for split layout
|
||||
- Left: trace list table (command / duration / timestamp), click → select_trace command
|
||||
- Right: placeholder (empty until Step 2.2)
|
||||
|
||||
`src/myfasthtml/assets/core/profiler.css`:
|
||||
- All `mf-profiler-*` classes
|
||||
|
||||
#### Step 2.2 — Detail panel ⏳
|
||||
|
||||
Right panel content:
|
||||
- Metadata and kwargs via `Properties`
|
||||
- Span tree: custom `_mk_span_tree()` with bars and cumulative badges
|
||||
- View toggle (tree / pie) in detail header
|
||||
|
||||
#### Step 2.3 — Pie chart ⏳ Future
|
||||
|
||||
`src/myfasthtml/controls/ProfilerPieChart.py`
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Control files: `ProfilerXxx.py`
|
||||
- CSS classes: `mf-profiler-xxx`
|
||||
- Logger: `logging.getLogger("Profiler")`
|
||||
- Constant: `PROFILER_MAX_TRACES = 500` in `src/myfasthtml/core/constants.py`
|
||||
640
examples/profiler_mockup.html
Normal file
640
examples/profiler_mockup.html
Normal 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>
|
||||
920
examples/profiler_mockup_2.html
Normal file
920
examples/profiler_mockup_2.html
Normal 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>
|
||||
17
src/app.py
17
src/app.py
@@ -13,13 +13,14 @@ from myfasthtml.controls.FileUpload import FileUpload
|
||||
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.Profiler import Profiler
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.helpers import Ids, mk
|
||||
from myfasthtml.core.dbengine_utils import DataFrameHandler
|
||||
from myfasthtml.core.instances import UniqueInstance
|
||||
from myfasthtml.icons.carbon import volume_object_storage
|
||||
from myfasthtml.icons.fluent_p2 import key_command16_regular
|
||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular, text_edit_style20_regular
|
||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular, text_edit_style20_regular, timer20_regular
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
with open('logging.yaml', 'r') as f:
|
||||
@@ -55,13 +56,19 @@ def index(session):
|
||||
btn_show_instances_debugger = mk.label("Instances",
|
||||
icon=volume_object_storage,
|
||||
command=add_tab("Instances", instances_debugger),
|
||||
id=instances_debugger.get_id())
|
||||
id=f"l_{instances_debugger.get_id()}")
|
||||
|
||||
commands_debugger = CommandsDebugger(layout)
|
||||
btn_show_commands_debugger = mk.label("Commands",
|
||||
icon=key_command16_regular,
|
||||
command=add_tab("Commands", commands_debugger),
|
||||
id=commands_debugger.get_id())
|
||||
id=f"l_{commands_debugger.get_id()}")
|
||||
|
||||
profiler = Profiler(layout)
|
||||
btn_show_profiler = mk.label("Profiler",
|
||||
icon=timer20_regular,
|
||||
command=add_tab("Profiler", profiler),
|
||||
id=f"l_{profiler.get_id()}")
|
||||
|
||||
btn_file_upload = mk.label("Upload",
|
||||
icon=folder_open20_regular,
|
||||
@@ -75,12 +82,14 @@ def index(session):
|
||||
layout.header_right.add(btn_show_right_drawer)
|
||||
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
|
||||
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
|
||||
layout.left_drawer.add(btn_show_profiler, "Debugger")
|
||||
|
||||
# Parameters
|
||||
formatting_manager = DataGridFormattingManager(layout)
|
||||
btn_show_formatting_manager = mk.label("Formatting",
|
||||
icon=text_edit_style20_regular,
|
||||
command=add_tab("Formatting", formatting_manager))
|
||||
command=add_tab("Formatting", formatting_manager),
|
||||
id=f"l_{formatting_manager.get_id()}")
|
||||
layout.left_drawer.add(btn_show_formatting_manager, "Parameters")
|
||||
|
||||
layout.left_drawer.add(btn_file_upload, "Test")
|
||||
|
||||
87
src/myfasthtml/assets/core/htmx_debug.js
Normal file
87
src/myfasthtml/assets/core/htmx_debug.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* HTMX debug tracing — toggle with window.HTMX_DEBUG = true in the browser console.
|
||||
*
|
||||
* Each request gets a unique ID (#1, #2, ...). Timings are deltas from beforeRequest.
|
||||
*
|
||||
* Full event sequence (→ = network boundary):
|
||||
* beforeRequest — request about to be sent
|
||||
* beforeSend — XHR about to be sent (after HTMX setup)
|
||||
* → network round-trip ←
|
||||
* beforeOnLoad — response received, HTMX about to process it
|
||||
* beforeSwap — DOM swap about to happen
|
||||
* afterSwap — DOM swap done
|
||||
* afterSettle — settle phase complete
|
||||
* afterRequest — full request lifecycle complete
|
||||
* sendError — network error
|
||||
* responseError — non-2xx response
|
||||
*/
|
||||
|
||||
window.HTMX_DEBUG = false;
|
||||
(function () {
|
||||
console.log('Debug HTMX: htmx.logAll();');
|
||||
console.log('Perf HTMX: window.HTMX_DEBUG=true;');
|
||||
})();
|
||||
|
||||
(function () {
|
||||
const EVENTS = [
|
||||
'htmx:beforeRequest',
|
||||
'htmx:beforeSend',
|
||||
'htmx:beforeOnLoad',
|
||||
'htmx:beforeSwap',
|
||||
'htmx:afterSwap',
|
||||
'htmx:afterSettle',
|
||||
'htmx:afterRequest',
|
||||
'htmx:sendError',
|
||||
'htmx:responseError',
|
||||
];
|
||||
|
||||
let counter = 0;
|
||||
const requests = new WeakMap();
|
||||
|
||||
function getInfo(detail) {
|
||||
const key = detail?.requestConfig ?? detail?.xhr ?? null;
|
||||
if (!key || !requests.has(key)) return null;
|
||||
return requests.get(key);
|
||||
}
|
||||
|
||||
EVENTS.forEach(eventName => {
|
||||
document.addEventListener(eventName, (e) => {
|
||||
if (!window.HTMX_DEBUG) return;
|
||||
|
||||
const short = eventName.replace('htmx:', '').padEnd(14);
|
||||
const path = e.detail?.requestConfig?.path ?? e.detail?.pathInfo?.requestPath ?? '';
|
||||
const isError = eventName === 'htmx:sendError' || eventName === 'htmx:responseError';
|
||||
|
||||
let prefix;
|
||||
|
||||
if (eventName === 'htmx:beforeRequest') {
|
||||
const key = e.detail?.requestConfig ?? null;
|
||||
if (key) {
|
||||
const id = ++counter;
|
||||
const now = performance.now();
|
||||
requests.set(key, {id, start: now, last: now});
|
||||
prefix = `#${String(id).padStart(3)} + 0.0ms (Δ 0.0ms)`;
|
||||
} else {
|
||||
prefix = `# ? + 0.0ms (Δ 0.0ms)`;
|
||||
}
|
||||
} else {
|
||||
const info = getInfo(e.detail);
|
||||
if (info) {
|
||||
const now = performance.now();
|
||||
const total = (now - info.start).toFixed(1);
|
||||
const step = (now - info.last).toFixed(1);
|
||||
info.last = now;
|
||||
prefix = `#${String(info.id).padStart(3)} +${String(total).padStart(7)}ms (Δ${String(step).padStart(7)}ms)`;
|
||||
} else {
|
||||
prefix = `# ? + ?.?ms (Δ ?.?ms)`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
console.warn(`[HTMX] ${prefix} ${short}`, path, e.detail);
|
||||
} else {
|
||||
console.debug(`[HTMX] ${prefix} ${short}`, path);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -1,7 +1,15 @@
|
||||
/**
|
||||
* Create keyboard bindings
|
||||
*/
|
||||
|
||||
// Set window.KEYBOARD_DEBUG = true in the browser console to enable traces
|
||||
window.KEYBOARD_DEBUG = false;
|
||||
|
||||
(function () {
|
||||
function kbLog(...args) {
|
||||
if (window.KEYBOARD_DEBUG) console.debug('[Keyboard]', ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global registry to store keyboard shortcuts for multiple elements
|
||||
*/
|
||||
@@ -172,8 +180,11 @@
|
||||
// Add snapshot to history
|
||||
KeyboardRegistry.snapshotHistory.push(snapshot);
|
||||
|
||||
kbLog(`key="${key}" | history length=${KeyboardRegistry.snapshotHistory.length} | registeredElements=${KeyboardRegistry.elements.size}`);
|
||||
|
||||
// Cancel any pending timeout
|
||||
if (KeyboardRegistry.pendingTimeout) {
|
||||
kbLog(` cancelled pending timeout`);
|
||||
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||
KeyboardRegistry.pendingTimeout = null;
|
||||
KeyboardRegistry.pendingMatches = [];
|
||||
@@ -198,8 +209,7 @@
|
||||
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
|
||||
|
||||
if (!currentNode) {
|
||||
// No match in this tree, continue to next element
|
||||
// console.debug("No match in tree for event", key);
|
||||
kbLog(` element="${elementId}" → no match in tree`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -212,6 +222,8 @@
|
||||
// Check if there are longer sequences possible (node has children)
|
||||
const hasLongerSequences = currentNode.children.size > 0;
|
||||
|
||||
kbLog(` element="${elementId}" | isInside=${isInside} | hasMatch=${hasMatch} | hasLongerSequences=${hasLongerSequences}`);
|
||||
|
||||
// Track if ANY element has longer sequences possible
|
||||
if (hasLongerSequences) {
|
||||
anyHasLongerSequence = true;
|
||||
@@ -221,6 +233,7 @@
|
||||
if (hasMatch) {
|
||||
const requireInside = currentNode.config["require_inside"] === true;
|
||||
const enabled = isCombinationEnabled(data.controlDivId, currentNode.combinationStr);
|
||||
kbLog(` combination="${currentNode.combinationStr}" | requireInside=${requireInside} | enabled=${enabled}`);
|
||||
if (enabled && (!requireInside || isInside)) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
@@ -228,10 +241,14 @@
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: isInside
|
||||
});
|
||||
} else {
|
||||
kbLog(` → skipped (requireInside=${requireInside} but isInside=${isInside}, or disabled)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kbLog(` result: matches=${currentMatches.length} | anyHasLongerSequence=${anyHasLongerSequence}`);
|
||||
|
||||
// Prevent default if we found any match and not in input context
|
||||
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||
event.preventDefault();
|
||||
@@ -239,6 +256,7 @@
|
||||
|
||||
// Decision logic based on matches and longer sequences
|
||||
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||
kbLog(` → TRIGGER immediately`);
|
||||
// We have matches and NO element has longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
@@ -249,6 +267,7 @@
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
|
||||
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||
kbLog(` → WAITING ${KeyboardRegistry.sequenceTimeout}ms (longer sequence possible)`);
|
||||
// We have matches but AT LEAST ONE element has longer sequences possible
|
||||
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||
|
||||
@@ -256,6 +275,7 @@
|
||||
const savedEvent = event; // Save event for timeout callback
|
||||
|
||||
KeyboardRegistry.pendingTimeout = setTimeout(() => {
|
||||
kbLog(` → TRIGGER after timeout`);
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of KeyboardRegistry.pendingMatches) {
|
||||
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, savedEvent);
|
||||
@@ -268,10 +288,12 @@
|
||||
}, KeyboardRegistry.sequenceTimeout);
|
||||
|
||||
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||
kbLog(` → WAITING (partial match, no full match yet)`);
|
||||
// No matches yet but longer sequences are possible
|
||||
// Just wait, don't trigger anything
|
||||
|
||||
} else {
|
||||
kbLog(` → NO MATCH, clearing history`);
|
||||
// No matches and no longer sequences possible
|
||||
// This is an invalid sequence - clear history
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
@@ -280,11 +302,13 @@
|
||||
// If we found no match at all, clear the history
|
||||
// This handles invalid sequences like "A C" when only "A B" exists
|
||||
if (!foundAnyMatch) {
|
||||
kbLog(` → foundAnyMatch=false, clearing history`);
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// Also clear history if it gets too long (prevent memory issues)
|
||||
if (KeyboardRegistry.snapshotHistory.length > 10) {
|
||||
kbLog(` → history too long, clearing`);
|
||||
KeyboardRegistry.snapshotHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
340
src/myfasthtml/assets/core/profiler.css
Normal file
340
src/myfasthtml/assets/core/profiler.css
Normal file
@@ -0,0 +1,340 @@
|
||||
/* ================================================================== */
|
||||
/* Profiler Control */
|
||||
/* ================================================================== */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Root wrapper — fills parent, stacks toolbar above panel
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Toolbar
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Danger variant for clear button */
|
||||
.mf-profiler-btn-danger {
|
||||
color: var(--color-error) !important;
|
||||
}
|
||||
|
||||
.mf-profiler-btn-danger:hover {
|
||||
background: color-mix(in oklab, var(--color-error) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Overhead metrics — right-aligned text */
|
||||
.mf-profiler-overhead {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Trace list — left panel content
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 96px;
|
||||
padding: 4px 10px;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: var(--text-xs);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-col-header {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mf-profiler-col-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mf-profiler-list-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Trace row
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 96px;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.mf-profiler-row:hover {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-profiler-row-selected {
|
||||
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.mf-profiler-row-selected:hover {
|
||||
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
/* Command name + description cell */
|
||||
.mf-profiler-cmd-cell {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-cmd {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-cmd-description {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
font-style: italic;
|
||||
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Duration cell — monospace, color-coded */
|
||||
.mf-profiler-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mf-profiler-fast {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.mf-profiler-medium {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.mf-profiler-slow {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Timestamp cell */
|
||||
.mf-profiler-ts {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-align: right;
|
||||
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Detail panel — right side
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-title {
|
||||
flex: 1;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-cmd {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mf-profiler-detail-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.mf-profiler-view-toggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-view-btn-active {
|
||||
color: var(--color-primary) !important;
|
||||
background: color-mix(in oklab, var(--color-primary) 12%, transparent) !important;
|
||||
}
|
||||
|
||||
.mf-profiler-detail-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Span tree — inside a Properties group card
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-span-tree-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 50%, transparent);
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mf-profiler-span-row:hover {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-profiler-span-indent {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
align-self: stretch;
|
||||
border-left: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
|
||||
}
|
||||
|
||||
.mf-profiler-span-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name {
|
||||
min-width: 120px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-name-root {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar-bg {
|
||||
flex: 1;
|
||||
height: 5px;
|
||||
background: color-mix(in oklab, var(--color-border) 80%, transparent);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.mf-profiler-medium {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.mf-profiler-span-bar.mf-profiler-slow {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: color-mix(in oklab, var(--color-base-content) 55%, transparent);
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms.mf-profiler-medium {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.mf-profiler-span-ms.mf-profiler-slow {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.mf-profiler-cumulative-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary) 30%, transparent);
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Empty state
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
|
||||
}
|
||||
@@ -146,6 +146,15 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dt2-cell-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dt2-cell-input:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dt2-cell:hover,
|
||||
.dt2-selected-cell {
|
||||
background-color: var(--color-selection);
|
||||
|
||||
@@ -698,7 +698,7 @@ function updateDatagridSelection(datagridId) {
|
||||
if (cellElement) {
|
||||
cellElement.classList.add('dt2-selected-focus');
|
||||
cellElement.style.userSelect = 'text';
|
||||
cellElement.focus({ preventScroll: true });
|
||||
requestAnimationFrame(() => cellElement.focus({ preventScroll: false }));
|
||||
hasFocusedCell = true;
|
||||
}
|
||||
} else if (selectionType === 'cell') {
|
||||
@@ -750,7 +750,7 @@ function updateDatagridSelection(datagridId) {
|
||||
|
||||
if (!hasFocusedCell) {
|
||||
const grid = document.getElementById(datagridId);
|
||||
if (grid) grid.focus({ preventScroll: true });
|
||||
if (grid) requestAnimationFrame(() => grid.focus({ preventScroll: true }));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@ class Boundaries(SingleInstance):
|
||||
Keep the boundaries updated
|
||||
"""
|
||||
|
||||
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
|
||||
super().__init__(owner, _id=_id)
|
||||
self._owner = owner
|
||||
self._container_id = container_id or owner.get_id()
|
||||
def __init__(self, parent, container_id: str = None, on_resize=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._owner = parent
|
||||
self._container_id = container_id or parent.get_id()
|
||||
self._on_resize = on_resize
|
||||
self._commands = Commands(self)
|
||||
self._state = BoundariesState()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import bisect
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
@@ -80,6 +81,7 @@ class DatagridState(DbObject):
|
||||
self.selection: DatagridSelectionState = DatagridSelectionState()
|
||||
self.cell_formats: dict = {}
|
||||
self.table_format: list = []
|
||||
self.ns_visible_indices: list[int] | None = None
|
||||
|
||||
|
||||
class DatagridSettings(DbObject):
|
||||
@@ -270,7 +272,7 @@ class DataGrid(MultipleInstance):
|
||||
self._datagrid_filter.bind_command("ChangeFilterType", self.commands.filter())
|
||||
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
|
||||
|
||||
# add Selection Selector
|
||||
# add Selection Selector (cell, row, column)
|
||||
selection_types = {
|
||||
"cell": mk.icon(grid, tooltip="Cell selection"), # default
|
||||
"row": mk.icon(row, tooltip="Row selection"),
|
||||
@@ -403,8 +405,12 @@ class DataGrid(MultipleInstance):
|
||||
if col.visible and col.type != ColumnType.RowSelection_]
|
||||
|
||||
def _get_visible_row_indices(self) -> list[int]:
|
||||
df = self._get_filtered_df()
|
||||
return list(df.index) if df is not None else []
|
||||
if self._state.ns_visible_indices is None:
|
||||
if self._df is None:
|
||||
self._state.ns_visible_indices = []
|
||||
else:
|
||||
self._state.ns_visible_indices = list(self._apply_filter(self._df).index)
|
||||
return self._state.ns_visible_indices
|
||||
|
||||
def _navigate(self, pos: tuple, direction: str) -> tuple:
|
||||
col_pos, row_index = pos
|
||||
@@ -421,11 +427,11 @@ class DataGrid(MultipleInstance):
|
||||
prev_cols = [c for c in navigable_cols if c < col_pos]
|
||||
return (prev_cols[-1], row_index) if prev_cols else pos
|
||||
elif direction == "down":
|
||||
next_rows = [r for r in visible_rows if r > row_index]
|
||||
return (col_pos, next_rows[0]) if next_rows else pos
|
||||
next_pos = bisect.bisect_right(visible_rows, row_index)
|
||||
return (col_pos, visible_rows[next_pos]) if next_pos < len(visible_rows) else pos
|
||||
elif direction == "up":
|
||||
prev_rows = [r for r in visible_rows if r < row_index]
|
||||
return (col_pos, prev_rows[-1]) if prev_rows else pos
|
||||
prev_pos = bisect.bisect_left(visible_rows, row_index) - 1
|
||||
return (col_pos, visible_rows[prev_pos]) if prev_pos >= 0 else pos
|
||||
|
||||
return pos
|
||||
|
||||
@@ -439,9 +445,13 @@ class DataGrid(MultipleInstance):
|
||||
|
||||
return None
|
||||
|
||||
def _update_current_position(self, pos):
|
||||
def _update_current_position(self, pos, reset_selection: bool = False):
|
||||
self._state.selection.last_selected = self._state.selection.selected
|
||||
self._state.selection.selected = pos
|
||||
|
||||
if reset_selection:
|
||||
self._state.selection.extra_selected.clear()
|
||||
self._state.edition.under_edition = None
|
||||
self._state.save()
|
||||
|
||||
def _get_format_rules(self, col_pos, row_index, col_def):
|
||||
@@ -508,14 +518,23 @@ class DataGrid(MultipleInstance):
|
||||
self._columns.insert(0, DataGridRowSelectionColumnState())
|
||||
|
||||
def _enter_edition(self, pos):
|
||||
logger.debug(f"enter_edition: {pos=}")
|
||||
col_pos, row_index = pos
|
||||
col_def = self._columns[col_pos]
|
||||
if col_def.type in (ColumnType.RowSelection_, ColumnType.RowIndex, ColumnType.Formula):
|
||||
return self.render_partial()
|
||||
if col_def.type == ColumnType.Bool:
|
||||
return self._toggle_bool_cell(col_pos, row_index, col_def)
|
||||
self._state.edition.under_edition = pos
|
||||
self._state.save()
|
||||
return self.render_partial("cell", pos=pos)
|
||||
|
||||
def _toggle_bool_cell(self, col_pos, row_index, col_def):
|
||||
col_array = self._fast_access.get(col_def.col_id)
|
||||
current_value = col_array[row_index] if col_array is not None and row_index < len(col_array) else False
|
||||
self._data_service.set_data(col_def.col_id, row_index, not bool(current_value))
|
||||
return self.render_partial("cell", pos=(col_pos, row_index))
|
||||
|
||||
def _convert_edition_value(self, value_str, col_type):
|
||||
if col_type == ColumnType.Number:
|
||||
try:
|
||||
@@ -645,11 +664,13 @@ class DataGrid(MultipleInstance):
|
||||
def filter(self):
|
||||
logger.debug("filter")
|
||||
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
|
||||
self._state.ns_visible_indices = None
|
||||
return self.render_partial("body")
|
||||
|
||||
def handle_on_click(self, combination, is_inside, cell_id):
|
||||
logger.debug(f"on_click table={self.get_table_name()} {combination=} {is_inside=} {cell_id=}")
|
||||
if is_inside and cell_id:
|
||||
logger.debug(f" is_inside=True")
|
||||
self._state.selection.extra_selected.clear()
|
||||
|
||||
pos = self._get_pos_from_element_id(cell_id)
|
||||
@@ -659,9 +680,15 @@ class DataGrid(MultipleInstance):
|
||||
pos == self._state.selection.selected and
|
||||
self._state.edition.under_edition is None):
|
||||
return self._enter_edition(pos)
|
||||
else:
|
||||
logger.debug(
|
||||
f" {pos=}, selected={self._state.selection.selected}, under_edition={self._state.edition.under_edition}")
|
||||
|
||||
self._update_current_position(pos)
|
||||
|
||||
else:
|
||||
logger.debug(f" is_inside=False")
|
||||
|
||||
return self.render_partial()
|
||||
|
||||
def on_mouse_selection(self, combination, is_inside, cell_id_mousedown, cell_id_mouseup):
|
||||
@@ -685,13 +712,15 @@ class DataGrid(MultipleInstance):
|
||||
def on_key_pressed(self, combination, has_focus, is_inside):
|
||||
logger.debug(f"on_key_pressed table={self.get_table_name()} {combination=} {has_focus=} {is_inside=}")
|
||||
if combination == "esc":
|
||||
self._update_current_position(None)
|
||||
self._state.selection.extra_selected.clear()
|
||||
self._update_current_position(None, reset_selection=True)
|
||||
return self.render_partial("cell", pos=self._state.selection.last_selected)
|
||||
|
||||
elif (combination == "enter" and
|
||||
self._settings.enable_edition and
|
||||
self._state.selection.selected and
|
||||
self._state.edition.under_edition is None):
|
||||
return self._enter_edition(self._state.selection.selected)
|
||||
|
||||
elif combination in self._ARROW_KEY_DIRECTIONS:
|
||||
current_pos = (self._state.selection.selected
|
||||
or self._state.selection.last_selected
|
||||
@@ -1139,25 +1168,19 @@ class DataGrid(MultipleInstance):
|
||||
)
|
||||
|
||||
def mk_selection_manager(self):
|
||||
|
||||
extra_attr = {
|
||||
"hx-on::after-settle": f"updateDatagridSelection('{self._id}');",
|
||||
}
|
||||
|
||||
selected = []
|
||||
|
||||
|
||||
if self._state.selection.selected:
|
||||
# selected.append(("cell", self._get_element_id_from_pos("cell", self._state.selection.selected)))
|
||||
selected.append(("focus", self._get_element_id_from_pos("cell", self._state.selection.selected)))
|
||||
|
||||
|
||||
for extra_sel in self._state.selection.extra_selected:
|
||||
selected.append(extra_sel)
|
||||
|
||||
|
||||
return Div(
|
||||
*[Div(selection_type=s_type, element_id=f"{elt_id}") for s_type, elt_id in selected],
|
||||
id=f"tsm_{self._id}",
|
||||
selection_mode=f"{self._state.selection.selection_mode}",
|
||||
**extra_attr,
|
||||
)
|
||||
|
||||
def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False):
|
||||
@@ -1244,7 +1267,8 @@ class DataGrid(MultipleInstance):
|
||||
id=self._id,
|
||||
cls="grid",
|
||||
style="height: 100%; grid-template-rows: auto 1fr;",
|
||||
tabindex="-1"
|
||||
tabindex="-1",
|
||||
**{"hx-on:htmx:after-swap": f"if(event.detail.target.id==='tsm_{self._id}') updateDatagridSelection('{self._id}');"}
|
||||
)
|
||||
|
||||
def render_partial(self, fragment="cell", **kwargs):
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from fastcore.basics import NotStr
|
||||
|
||||
from myfasthtml.core.constants import ColumnType
|
||||
from myfasthtml.core.constants import ColumnType, MediaActions
|
||||
from myfasthtml.core.utils import pascal_to_snake
|
||||
from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \
|
||||
number_row20_regular
|
||||
from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \
|
||||
checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_regular
|
||||
from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular
|
||||
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular
|
||||
checkbox_checked20_filled, math_formula16_regular, folder20_regular, document20_regular, pause_circle20_regular
|
||||
from myfasthtml.icons.fluent_p2 import text_field20_regular, text_bullet_list_square20_regular, play_circle20_regular, \
|
||||
dismiss_circle20_regular
|
||||
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular, record_stop20_regular
|
||||
|
||||
default_icons = {
|
||||
# default type icons
|
||||
@@ -22,6 +23,12 @@ default_icons = {
|
||||
"TreeViewFolder": folder20_regular,
|
||||
"TreeViewFile": document20_regular,
|
||||
|
||||
# Media
|
||||
MediaActions.Play: play_circle20_regular,
|
||||
MediaActions.Pause: pause_circle20_regular,
|
||||
MediaActions.Stop: record_stop20_regular,
|
||||
MediaActions.Cancel: dismiss_circle20_regular,
|
||||
|
||||
# Datagrid column icons
|
||||
ColumnType.RowIndex: number_symbol20_regular,
|
||||
ColumnType.Text: text_field20_regular,
|
||||
|
||||
@@ -4,7 +4,7 @@ from fasthtml.components import Div
|
||||
|
||||
from myfasthtml.controls.HierarchicalCanvasGraph import HierarchicalCanvasGraph, HierarchicalCanvasGraphConf
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.Properties import Properties
|
||||
from myfasthtml.controls.Properties import Properties, PropertiesConf
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, UniqueInstance, MultipleInstance, InstancesManager
|
||||
|
||||
@@ -73,10 +73,11 @@ class InstancesDebugger(SingleInstance):
|
||||
"Commands": {"*": "commands"},
|
||||
}
|
||||
|
||||
return self._panel.set_right(Properties(self,
|
||||
InstancesManager.get(session, instance_id),
|
||||
properties_def,
|
||||
_id="-properties"))
|
||||
return self._panel.set_right(Properties(
|
||||
self,
|
||||
conf=PropertiesConf(obj=InstancesManager.get(session, instance_id), groups=properties_def),
|
||||
_id="-properties",
|
||||
))
|
||||
|
||||
def _get_instance_kind(self, instance) -> str:
|
||||
"""Determine the instance kind for visualization.
|
||||
|
||||
@@ -104,7 +104,7 @@ class Panel(MultipleInstance):
|
||||
the panel with appropriate HTML elements and JavaScript for interactivity.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, conf: Optional[PanelConf] = None, _id=None):
|
||||
def __init__(self, parent, conf: Optional[PanelConf] = None, _id="-panel"):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or PanelConf()
|
||||
self.commands = Commands(self)
|
||||
@@ -232,7 +232,7 @@ class Panel(MultipleInstance):
|
||||
hide_icon,
|
||||
Div(content, id=self._ids.content(side)),
|
||||
cls=panel_cls,
|
||||
style=f"width: {self._state.left_width}px;",
|
||||
style=f"width: {self._state.right_width}px;",
|
||||
id=self._ids.panel(side)
|
||||
)
|
||||
|
||||
|
||||
423
src/myfasthtml/controls/Profiler.py
Normal file
423
src/myfasthtml/controls/Profiler.py
Normal file
@@ -0,0 +1,423 @@
|
||||
import logging
|
||||
|
||||
from fasthtml.components import Div, Span
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.IconsHelper import IconsHelper
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
from myfasthtml.controls.Properties import Properties, PropertiesConf
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.core.profiler import CumulativeSpan, ProfilingSpan, ProfilingTrace, profiler
|
||||
from myfasthtml.icons.fluent import (
|
||||
arrow_clockwise20_regular,
|
||||
data_pie24_regular,
|
||||
text_bullet_list_tree20_filled,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("Profiler")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Span tree renderer — module-level, passed via PropertiesConf.types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mk_span_rows(span, depth: int, total_ms: float) -> list:
|
||||
"""Recursively build span rows for the tree view.
|
||||
|
||||
Args:
|
||||
span: A ProfilingSpan or CumulativeSpan to render.
|
||||
depth: Current nesting depth (controls indentation).
|
||||
total_ms: Reference duration used to compute bar widths.
|
||||
|
||||
Returns:
|
||||
List of FT elements, one per span row (depth-first order).
|
||||
"""
|
||||
rows = []
|
||||
indent = [Div(cls="mf-profiler-span-indent") for _ in range(depth)]
|
||||
|
||||
if isinstance(span, CumulativeSpan):
|
||||
pct = (span.total_ms / total_ms * 100) if total_ms > 0 else 0
|
||||
duration_cls = _span_duration_cls(span.total_ms)
|
||||
badge = Span(
|
||||
f"×{span.count} · min {span.min_ms:.2f} · avg {span.avg_ms:.2f} · max {span.max_ms:.2f} ms",
|
||||
cls="mf-profiler-cumulative-badge",
|
||||
)
|
||||
row = Div(
|
||||
*indent,
|
||||
Div(
|
||||
Span(span.name, cls="mf-profiler-span-name"),
|
||||
Div(Div(style=f"width:{pct:.1f}%"), cls="mf-profiler-span-bar-bg"),
|
||||
Span(f"{span.total_ms:.1f} ms", cls=f"mf-profiler-span-ms {duration_cls}"),
|
||||
badge,
|
||||
cls="mf-profiler-span-body",
|
||||
),
|
||||
cls="mf-profiler-span-row",
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
else:
|
||||
pct = (span.duration_ms / total_ms * 100) if total_ms > 0 else 0
|
||||
duration_cls = _span_duration_cls(span.duration_ms)
|
||||
name_cls = "mf-profiler-span-name mf-profiler-span-name-root" if depth == 0 else "mf-profiler-span-name"
|
||||
row = Div(
|
||||
*indent,
|
||||
Div(
|
||||
Span(span.name, cls=name_cls),
|
||||
Div(Div(cls=f"mf-profiler-span-bar {duration_cls}", style=f"width:{pct:.1f}%"), cls="mf-profiler-span-bar-bg"),
|
||||
Span(f"{span.duration_ms:.1f} ms", cls=f"mf-profiler-span-ms {duration_cls}"),
|
||||
cls="mf-profiler-span-body",
|
||||
),
|
||||
cls="mf-profiler-span-row",
|
||||
)
|
||||
rows.append(row)
|
||||
for child in span.children:
|
||||
rows.extend(_mk_span_rows(child, depth + 1, total_ms))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def _span_duration_cls(duration_ms: float) -> str:
|
||||
"""Return the CSS modifier class for a span duration."""
|
||||
if duration_ms < 20:
|
||||
return "mf-profiler-fast"
|
||||
if duration_ms < 100:
|
||||
return "mf-profiler-medium"
|
||||
return "mf-profiler-slow"
|
||||
|
||||
|
||||
def _span_tree_renderer(span: ProfilingSpan, trace: ProfilingTrace):
|
||||
"""Renderer for ProfilingSpan values in a PropertiesConf.types mapping.
|
||||
|
||||
Args:
|
||||
span: The root span to render as a tree.
|
||||
trace: The parent trace, used to compute proportional bar widths.
|
||||
|
||||
Returns:
|
||||
A FT element containing the full span tree.
|
||||
"""
|
||||
rows = _mk_span_rows(span, 0, trace.total_duration_ms)
|
||||
return Div(*rows, cls="mf-profiler-span-tree-content")
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
|
||||
def toggle_detail_view(self):
|
||||
return Command(
|
||||
"ProfilerToggleDetailView",
|
||||
"Switch between tree and pie view",
|
||||
self._owner,
|
||||
self._owner.handle_toggle_detail_view,
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def toggle_enable(self):
|
||||
return Command(
|
||||
"ProfilerToggleEnable",
|
||||
"Enable / Disable profiler",
|
||||
self._owner,
|
||||
self._owner.handle_toggle_enable,
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def clear_traces(self):
|
||||
return Command(
|
||||
"ProfilerClearTraces",
|
||||
"Clear all recorded traces",
|
||||
self._owner,
|
||||
self._owner.handle_clear_traces,
|
||||
icon=IconsHelper.get(MediaActions.Cancel),
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def refresh(self):
|
||||
return Command(
|
||||
"ProfilerRefresh",
|
||||
"Refresh traces",
|
||||
self._owner,
|
||||
self._owner.handle_refresh,
|
||||
icon=arrow_clockwise20_regular,
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def select_trace(self, trace_id: str):
|
||||
return Command(
|
||||
"ProfilerSelectTrace",
|
||||
"Display trace details",
|
||||
self._owner,
|
||||
self._owner.handle_select_trace,
|
||||
kwargs={"trace_id": trace_id},
|
||||
).htmx(target=f"#tr_{trace_id}")
|
||||
|
||||
|
||||
class Profiler(SingleInstance):
|
||||
"""In-application profiler UI.
|
||||
|
||||
Displays all recorded traces in a scrollable list (left) and trace
|
||||
details in a resizable panel (right). The toolbar provides enable /
|
||||
disable toggle and clear actions via icon-only buttons.
|
||||
|
||||
Attributes:
|
||||
commands: Commands exposed by this control.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._panel = Panel(self, conf=PanelConf(show_right_title=False, show_display_right=False))
|
||||
self._panel.set_side_visible("right", True)
|
||||
self._selected_id: str | None = None
|
||||
self._detail_view: str = "tree"
|
||||
self.commands = Commands(self)
|
||||
logger.debug(f"Profiler created with id={self._id}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Command handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle_toggle_enable(self):
|
||||
"""Toggle profiler.enabled and re-render."""
|
||||
profiler.enabled = not profiler.enabled
|
||||
logger.debug(f"Profiler enabled set to {profiler.enabled}")
|
||||
return self
|
||||
|
||||
def handle_clear_traces(self):
|
||||
"""Clear the trace buffer and re-render."""
|
||||
profiler.clear()
|
||||
logger.debug("Profiler traces cleared from UI")
|
||||
return self
|
||||
|
||||
def handle_select_trace(self, trace_id: str):
|
||||
"""Select a trace row and re-render to show it highlighted."""
|
||||
if self._selected_id is not None:
|
||||
old_trace = next(trace for trace in profiler.traces if trace.trace_id == self._selected_id)
|
||||
else:
|
||||
old_trace = None
|
||||
|
||||
self._selected_id = trace_id
|
||||
trace = next(trace for trace in profiler.traces if trace.trace_id == trace_id)
|
||||
|
||||
return (self._mk_trace_item(trace),
|
||||
self._mk_trace_item(old_trace),
|
||||
self._panel.set_right(self._mk_right_panel(trace)))
|
||||
|
||||
def handle_toggle_detail_view(self):
|
||||
"""Toggle detail panel between tree and pie view."""
|
||||
self._detail_view = "pie" if self._detail_view == "tree" else "tree"
|
||||
logger.debug(f"Profiler detail view set to {self._detail_view}")
|
||||
return self
|
||||
|
||||
def handle_refresh(self):
|
||||
"""Refresh the trace list without changing selection."""
|
||||
return self
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private rendering helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _duration_cls(self, duration_ms: float) -> str:
|
||||
"""Return the CSS modifier class for a given duration."""
|
||||
if duration_ms < 20:
|
||||
return "mf-profiler-fast"
|
||||
if duration_ms < 100:
|
||||
return "mf-profiler-medium"
|
||||
return "mf-profiler-slow"
|
||||
|
||||
def _mk_toolbar(self):
|
||||
"""Build the icon toolbar with enable/disable, clear and overhead metrics."""
|
||||
enable_icon = (
|
||||
IconsHelper.get(MediaActions.Pause)
|
||||
if profiler.enabled
|
||||
else IconsHelper.get(MediaActions.Play)
|
||||
)
|
||||
enable_tooltip = "Disable profiler" if profiler.enabled else "Enable profiler"
|
||||
|
||||
overhead = (
|
||||
f"Overhead/span: {profiler.overhead_per_span_us:.1f} µs "
|
||||
f"Total: {profiler.total_overhead_ms:.3f} ms "
|
||||
f"Traces: {len(profiler.traces)} / {PROFILER_MAX_TRACES}"
|
||||
)
|
||||
|
||||
return Div(
|
||||
mk.icon(
|
||||
enable_icon,
|
||||
command=self.commands.toggle_enable(),
|
||||
tooltip=enable_tooltip,
|
||||
),
|
||||
mk.icon(
|
||||
command=self.commands.clear_traces(),
|
||||
tooltip="Clear traces",
|
||||
cls="mf-profiler-btn-danger",
|
||||
),
|
||||
mk.icon(
|
||||
command=self.commands.refresh(),
|
||||
),
|
||||
Span(overhead, cls="mf-profiler-overhead"),
|
||||
cls="mf-profiler-toolbar",
|
||||
id=f"tb_{self._id}",
|
||||
)
|
||||
|
||||
def _mk_trace_item(self, trace: ProfilingTrace):
|
||||
if trace is None:
|
||||
return None
|
||||
|
||||
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
|
||||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
||||
row_cls = "mf-profiler-row mf-profiler-row-selected" if trace.trace_id == self._selected_id else "mf-profiler-row"
|
||||
|
||||
return mk.mk(
|
||||
Div(
|
||||
Div(
|
||||
Span(trace.command_name, cls="mf-profiler-cmd"),
|
||||
Span(trace.command_description, cls="mf-profiler-cmd-description"),
|
||||
cls="mf-profiler-cmd-cell",
|
||||
),
|
||||
Span(f"{trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-duration {duration_cls}"),
|
||||
Span(ts, cls="mf-profiler-ts"),
|
||||
cls=row_cls,
|
||||
id=f"tr_{trace.trace_id}",
|
||||
),
|
||||
command=self.commands.select_trace(trace.trace_id),
|
||||
)
|
||||
|
||||
def _mk_trace_list(self):
|
||||
"""Build the trace list with one clickable row per recorded trace."""
|
||||
traces = profiler.traces
|
||||
if not traces:
|
||||
return Div("No traces recorded.", cls="mf-profiler-empty")
|
||||
|
||||
rows = [self._mk_trace_item(trace) for trace in reversed(traces)]
|
||||
|
||||
return Div(
|
||||
Div(
|
||||
Span("Command", cls="mf-profiler-col-header"),
|
||||
Span("Duration", cls="mf-profiler-col-header mf-profiler-col-right"),
|
||||
Span("Time", cls="mf-profiler-col-header mf-profiler-col-right"),
|
||||
cls="mf-profiler-list-header",
|
||||
),
|
||||
Div(*rows, cls="mf-profiler-list-body"),
|
||||
cls="mf-profiler-list",
|
||||
)
|
||||
|
||||
def _mk_detail_placeholder(self):
|
||||
"""Placeholder shown in the right panel before a trace is selected."""
|
||||
return Div("Select a trace to view details.", cls="mf-profiler-empty")
|
||||
|
||||
def _mk_detail_header(self, trace: "ProfilingTrace"):
|
||||
"""Build the detail panel header with title and tree/pie toggle.
|
||||
|
||||
Args:
|
||||
trace: The selected trace.
|
||||
|
||||
Returns:
|
||||
A FT element for the detail header.
|
||||
"""
|
||||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
||||
title = Div(
|
||||
Span(trace.command_name, cls="mf-profiler-detail-cmd"),
|
||||
Span(f" — {trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-detail-duration {duration_cls}"),
|
||||
cls="mf-profiler-detail-title",
|
||||
)
|
||||
tree_cls = "mf-profiler-view-btn mf-profiler-view-btn-active" if self._detail_view == "tree" else "mf-profiler-view-btn"
|
||||
pie_cls = "mf-profiler-view-btn mf-profiler-view-btn-active" if self._detail_view == "pie" else "mf-profiler-view-btn"
|
||||
toggle = Div(
|
||||
mk.icon(text_bullet_list_tree20_filled, command=self.commands.toggle_detail_view(), tooltip="Span tree",
|
||||
cls=tree_cls),
|
||||
mk.icon(data_pie24_regular, command=self.commands.toggle_detail_view(), tooltip="Pie chart (coming soon)",
|
||||
cls=pie_cls),
|
||||
cls="mf-profiler-view-toggle",
|
||||
)
|
||||
return Div(title, toggle, cls="mf-profiler-detail-header")
|
||||
|
||||
def _mk_detail_body(self, trace: "ProfilingTrace"):
|
||||
"""Build the scrollable detail body: metadata, kwargs and span breakdown.
|
||||
|
||||
Args:
|
||||
trace: The selected trace.
|
||||
|
||||
Returns:
|
||||
A FT element for the detail body.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
meta_props = Properties(
|
||||
self,
|
||||
conf=PropertiesConf(
|
||||
obj=trace,
|
||||
groups={"Metadata": {
|
||||
"command": "command_name",
|
||||
"description": "command_description",
|
||||
"duration_ms": "total_duration_ms",
|
||||
"timestamp": "timestamp",
|
||||
}},
|
||||
),
|
||||
_id="-detail-meta",
|
||||
)
|
||||
|
||||
kwargs_obj = SimpleNamespace(**trace.kwargs) if trace.kwargs else SimpleNamespace()
|
||||
kwargs_props = Properties(
|
||||
self,
|
||||
conf=PropertiesConf(obj=kwargs_obj, groups={"kwargs": {"*": ""}}),
|
||||
_id="-detail-kwargs",
|
||||
)
|
||||
|
||||
span_props = None
|
||||
if trace.root_span is not None:
|
||||
span_props = Properties(
|
||||
self,
|
||||
conf=PropertiesConf(
|
||||
obj=trace,
|
||||
groups={"Span breakdown": {"root_span": "root_span"}},
|
||||
types={ProfilingSpan: _span_tree_renderer},
|
||||
),
|
||||
_id="-detail-spans",
|
||||
)
|
||||
|
||||
if self._detail_view == "pie":
|
||||
pie_placeholder = Div("Pie chart — coming soon.", cls="mf-profiler-empty")
|
||||
return Div(meta_props, kwargs_props, pie_placeholder, cls="mf-profiler-detail-body")
|
||||
|
||||
return Div(meta_props, kwargs_props, span_props, cls="mf-profiler-detail-body")
|
||||
|
||||
def _mk_detail_panel(self, trace: "ProfilingTrace"):
|
||||
"""Build the full detail panel for a selected trace.
|
||||
|
||||
Args:
|
||||
trace: The selected trace.
|
||||
|
||||
Returns:
|
||||
A FT element for the detail panel.
|
||||
"""
|
||||
return Div(
|
||||
self._mk_detail_header(trace),
|
||||
self._mk_detail_body(trace),
|
||||
cls="mf-profiler-detail",
|
||||
)
|
||||
|
||||
def _mk_right_panel(self, trace: "ProfilingTrace"):
|
||||
"""Build the right panel with a trace detail view."""
|
||||
return (
|
||||
self._mk_detail_panel(trace)
|
||||
if trace is not None
|
||||
else self._mk_detail_placeholder()
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self):
|
||||
selected_trace = None
|
||||
if self._selected_id is not None:
|
||||
selected_trace = next(
|
||||
(t for t in profiler.traces if t.trace_id == self._selected_id), None
|
||||
)
|
||||
|
||||
self._panel.set_main(self._mk_trace_list())
|
||||
self._panel.set_right(self._mk_right_panel(selected_trace))
|
||||
return Div(
|
||||
self._mk_toolbar(),
|
||||
self._panel,
|
||||
id=self._id,
|
||||
cls="mf-profiler",
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,21 +1,42 @@
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from fasthtml.components import Div
|
||||
from myutils.ProxyObject import ProxyObject
|
||||
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
@dataclass
|
||||
class PropertiesConf:
|
||||
"""Declarative configuration for the Properties control.
|
||||
|
||||
Attributes:
|
||||
obj: The Python object whose attributes are displayed.
|
||||
groups: Mapping of group name to ProxyObject spec.
|
||||
types: Mapping of Python type to renderer callable.
|
||||
Each renderer has the signature ``(value, obj) -> FT``.
|
||||
"""
|
||||
|
||||
obj: Any = None
|
||||
groups: Optional[dict] = None
|
||||
types: Optional[dict] = field(default=None)
|
||||
|
||||
|
||||
class Properties(MultipleInstance):
|
||||
def __init__(self, parent, obj=None, groups: dict = None, _id=None):
|
||||
def __init__(self, parent, conf: PropertiesConf = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.obj = obj
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def set_obj(self, obj, groups: dict = None):
|
||||
self.obj = obj
|
||||
self.groups = groups
|
||||
self.properties_by_group = self._create_properties_by_group()
|
||||
|
||||
self.conf = conf or PropertiesConf()
|
||||
self._refresh()
|
||||
|
||||
def set_conf(self, conf: PropertiesConf):
|
||||
self.conf = conf
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self):
|
||||
self._types = self.conf.types or {}
|
||||
self._properties_by_group = self._create_properties_by_group()
|
||||
|
||||
def _mk_group_content(self, properties: dict):
|
||||
return Div(
|
||||
*[
|
||||
@@ -28,40 +49,68 @@ class Properties(MultipleInstance):
|
||||
],
|
||||
cls="mf-properties-group-content"
|
||||
)
|
||||
|
||||
|
||||
def _mk_property_value(self, value):
|
||||
for t, renderer in self._types.items():
|
||||
if isinstance(value, t):
|
||||
return renderer(value, self.conf.obj)
|
||||
|
||||
if isinstance(value, dict):
|
||||
return self._mk_group_content(value)
|
||||
|
||||
|
||||
if isinstance(value, (list, tuple)):
|
||||
return self._mk_group_content({i: item for i, item in enumerate(value)})
|
||||
|
||||
|
||||
return Div(str(value),
|
||||
cls="mf-properties-value",
|
||||
title=str(value))
|
||||
|
||||
|
||||
def _render_group_content(self, proxy) -> Div:
|
||||
"""Render a group's content.
|
||||
|
||||
When the group contains exactly one property whose type is registered in
|
||||
``conf.types``, the type renderer replaces the entire group content (not
|
||||
just the value cell). This lets custom renderers (e.g. span trees) fill
|
||||
the full card width without a key/value row wrapper.
|
||||
|
||||
Otherwise, the standard key/value row layout is used.
|
||||
|
||||
Args:
|
||||
proxy: ProxyObject for this group.
|
||||
|
||||
Returns:
|
||||
A FT element containing the group content.
|
||||
"""
|
||||
properties = proxy.as_dict()
|
||||
if len(properties) == 1:
|
||||
k, v = next(iter(properties.items()))
|
||||
for t, renderer in self._types.items():
|
||||
if isinstance(v, t):
|
||||
return renderer(v, self.conf.obj)
|
||||
return self._mk_group_content(properties)
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
*[
|
||||
Div(
|
||||
Div(
|
||||
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
||||
self._mk_group_content(proxy.as_dict()),
|
||||
self._render_group_content(proxy),
|
||||
cls="mf-properties-group-container"
|
||||
),
|
||||
cls="mf-properties-group-card"
|
||||
)
|
||||
for group_name, proxy in self.properties_by_group.items()
|
||||
for group_name, proxy in self._properties_by_group.items()
|
||||
],
|
||||
id=self._id,
|
||||
cls="mf-properties"
|
||||
)
|
||||
|
||||
|
||||
def _create_properties_by_group(self):
|
||||
if self.groups is None:
|
||||
return {None: ProxyObject(self.obj, {"*": ""})}
|
||||
|
||||
return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()}
|
||||
|
||||
if self.conf.groups is None:
|
||||
return {None: ProxyObject(self.conf.obj, {"*": ""})}
|
||||
|
||||
return {k: ProxyObject(self.conf.obj, v) for k, v in self.conf.groups.items()}
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
|
||||
@@ -2,6 +2,8 @@ from enum import Enum
|
||||
|
||||
NO_DEFAULT_VALUE = object()
|
||||
|
||||
PROFILER_MAX_TRACES = 500
|
||||
|
||||
ROUTE_ROOT = "/myfasthtml"
|
||||
|
||||
# Datagrid
|
||||
@@ -31,6 +33,13 @@ class ColumnType(Enum):
|
||||
Formula = "Formula"
|
||||
|
||||
|
||||
class MediaActions(Enum):
|
||||
Play = "Play"
|
||||
Pause = "Pause"
|
||||
Stop = "Stop"
|
||||
Cancel = "Cancel"
|
||||
|
||||
|
||||
def get_columns_types() -> list[ColumnType]:
|
||||
return [c for c in ColumnType if not c.value.endswith("_")]
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import inspect
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional, Literal
|
||||
@@ -32,9 +33,15 @@ class BaseInstance:
|
||||
if VERBOSE_VERBOSE:
|
||||
logger.debug(f"Creating new instance of type {cls.__name__}")
|
||||
|
||||
parent = args[0] if len(args) > 0 and isinstance(args[0], BaseInstance) else kwargs.get("parent", None)
|
||||
session = args[1] if len(args) > 1 and isinstance(args[1], dict) else kwargs.get("session", None)
|
||||
_id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None)
|
||||
sig = inspect.signature(cls.__init__)
|
||||
bound = sig.bind_partial(None, *args, **kwargs) # None pour 'self'
|
||||
bound.apply_defaults()
|
||||
arguments = bound.arguments
|
||||
|
||||
parent = arguments.get("parent", None)
|
||||
session = arguments.get("session", None)
|
||||
_id = arguments.get("_id", None)
|
||||
|
||||
if VERBOSE_VERBOSE:
|
||||
logger.debug(f" parent={parent}, session={debug_session(session)}, _id={_id}")
|
||||
|
||||
@@ -247,7 +254,7 @@ class InstancesManager:
|
||||
"""
|
||||
key = (InstancesManager.get_session_id(session), instance.get_id())
|
||||
|
||||
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
|
||||
if key in InstancesManager.instances and not isinstance(instance, UniqueInstance):
|
||||
raise DuplicateInstanceError(instance)
|
||||
|
||||
InstancesManager.instances[key] = instance
|
||||
|
||||
560
src/myfasthtml/core/profiler.py
Normal file
560
src/myfasthtml/core/profiler.py
Normal file
@@ -0,0 +1,560 @@
|
||||
import functools
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
logger = logging.getLogger("Profiler")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No-op sentinel — used when profiler is disabled
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _NullSpan:
|
||||
"""No-op span returned when profiler is disabled."""
|
||||
|
||||
def set(self, key: str, value) -> '_NullSpan':
|
||||
return self
|
||||
|
||||
def __enter__(self) -> '_NullSpan':
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def __call__(self, fn):
|
||||
return fn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CumulativeSpan:
|
||||
"""Aggregated span for loops — one entry regardless of iteration count.
|
||||
|
||||
Attributes:
|
||||
name: Span name.
|
||||
count: Number of iterations recorded.
|
||||
total_ms: Cumulative duration in milliseconds.
|
||||
min_ms: Shortest recorded iteration in milliseconds.
|
||||
max_ms: Longest recorded iteration in milliseconds.
|
||||
"""
|
||||
|
||||
name: str
|
||||
count: int = 0
|
||||
total_ms: float = 0.0
|
||||
min_ms: float = float('inf')
|
||||
max_ms: float = 0.0
|
||||
|
||||
@property
|
||||
def avg_ms(self) -> float:
|
||||
"""Average duration per iteration in milliseconds."""
|
||||
return self.total_ms / self.count if self.count > 0 else 0.0
|
||||
|
||||
def record(self, duration_ms: float):
|
||||
"""Record one iteration.
|
||||
|
||||
Args:
|
||||
duration_ms: Duration of this iteration in milliseconds.
|
||||
"""
|
||||
self.count += 1
|
||||
self.total_ms += duration_ms
|
||||
if duration_ms < self.min_ms:
|
||||
self.min_ms = duration_ms
|
||||
if duration_ms > self.max_ms:
|
||||
self.max_ms = duration_ms
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfilingSpan:
|
||||
"""A named timing segment.
|
||||
|
||||
Attributes:
|
||||
name: Span name.
|
||||
data: Arbitrary metadata attached via span.set().
|
||||
children: Nested spans and cumulative spans.
|
||||
duration_ms: Duration of this span in milliseconds (set on finish).
|
||||
"""
|
||||
|
||||
name: str
|
||||
data: dict = field(default_factory=dict)
|
||||
children: list = field(default_factory=list)
|
||||
_start: float = field(default_factory=time.perf_counter, repr=False)
|
||||
duration_ms: float = field(default=0.0)
|
||||
|
||||
def set(self, key: str, value) -> 'ProfilingSpan':
|
||||
"""Attach metadata to this span.
|
||||
|
||||
Args:
|
||||
key: Metadata key.
|
||||
value: Metadata value.
|
||||
|
||||
Returns:
|
||||
Self, for chaining.
|
||||
"""
|
||||
self.data[key] = value
|
||||
return self
|
||||
|
||||
def finish(self):
|
||||
"""Stop timing and record the duration."""
|
||||
self.duration_ms = (time.perf_counter() - self._start) * 1000
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProfilingTrace:
|
||||
"""One complete command execution, from route handler to response.
|
||||
|
||||
Attributes:
|
||||
command_name: Name of the executed command.
|
||||
command_description: Human-readable description of the command.
|
||||
command_id: UUID of the command.
|
||||
kwargs: Arguments received from the client.
|
||||
timestamp: When the command was received.
|
||||
root_span: Top-level span wrapping the full execution.
|
||||
total_duration_ms: Total server-side duration in milliseconds.
|
||||
"""
|
||||
|
||||
command_name: str
|
||||
command_description: str
|
||||
command_id: str
|
||||
kwargs: dict
|
||||
timestamp: datetime
|
||||
root_span: Optional[ProfilingSpan] = None
|
||||
total_duration_ms: float = 0.0
|
||||
trace_id: str = field(default_factory=lambda: str(uuid4()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context managers / decorators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _ActiveSpan:
|
||||
"""Context manager and decorator for a single named span.
|
||||
|
||||
When used as a context manager, returns the ProfilingSpan so callers can
|
||||
attach metadata via ``span.set()``. When used as a decorator, captures
|
||||
function arguments automatically.
|
||||
"""
|
||||
|
||||
def __init__(self, manager: 'ProfilingManager', name: str, args: dict = None):
|
||||
self._manager = manager
|
||||
self._name = name
|
||||
self._args = args
|
||||
self._span: Optional[ProfilingSpan] = None
|
||||
self._token = None
|
||||
|
||||
def __enter__(self) -> ProfilingSpan:
|
||||
if not self._manager.enabled:
|
||||
return _NullSpan()
|
||||
|
||||
overhead_start = time.perf_counter()
|
||||
self._span = ProfilingSpan(name=self._name)
|
||||
if self._args:
|
||||
self._span.data.update(self._args)
|
||||
self._token = self._manager.push_span(self._span)
|
||||
self._manager.record_overhead(time.perf_counter() - overhead_start)
|
||||
return self._span
|
||||
|
||||
def __exit__(self, *args):
|
||||
if self._span is not None:
|
||||
overhead_start = time.perf_counter()
|
||||
self._manager.pop_span(self._span, self._token)
|
||||
self._manager.record_overhead(time.perf_counter() - overhead_start)
|
||||
|
||||
def __call__(self, fn):
|
||||
"""Use as decorator — enabled check deferred to call time."""
|
||||
manager = self._manager
|
||||
name = self._name
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not manager.enabled:
|
||||
return fn(*args, **kwargs)
|
||||
captured = manager.capture_args(fn, args, kwargs)
|
||||
with _ActiveSpan(manager, name, captured):
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class _CumulativeActiveSpan:
|
||||
"""Context manager and decorator for cumulative spans.
|
||||
|
||||
Finds or creates a CumulativeSpan in the current parent and records
|
||||
each iteration without adding a new child entry.
|
||||
"""
|
||||
|
||||
def __init__(self, manager: 'ProfilingManager', name: str):
|
||||
self._manager = manager
|
||||
self._name = name
|
||||
self._cumulative_span: Optional[CumulativeSpan] = None
|
||||
self._iter_start: float = 0.0
|
||||
|
||||
def _get_or_create(self) -> CumulativeSpan:
|
||||
parent = self._manager.current_span()
|
||||
if isinstance(parent, ProfilingSpan):
|
||||
for child in parent.children:
|
||||
if isinstance(child, CumulativeSpan) and child.name == self._name:
|
||||
return child
|
||||
cumulative_span = CumulativeSpan(name=self._name)
|
||||
parent.children.append(cumulative_span)
|
||||
return cumulative_span
|
||||
return CumulativeSpan(name=self._name)
|
||||
|
||||
def __enter__(self) -> CumulativeSpan:
|
||||
if not self._manager.enabled:
|
||||
return _NullSpan()
|
||||
self._cumulative_span = self._get_or_create()
|
||||
self._iter_start = time.perf_counter()
|
||||
return self._cumulative_span
|
||||
|
||||
def __exit__(self, *args):
|
||||
if self._cumulative_span is not None:
|
||||
duration_ms = (time.perf_counter() - self._iter_start) * 1000
|
||||
self._cumulative_span.record(duration_ms)
|
||||
|
||||
def __call__(self, fn):
|
||||
manager = self._manager
|
||||
name = self._name
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
with _CumulativeActiveSpan(manager, name):
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class _CommandSpan:
|
||||
"""Context manager that creates both a ProfilingTrace and its root span.
|
||||
|
||||
Used exclusively by the route handler to wrap the full command execution.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
manager: 'ProfilingManager',
|
||||
command_name: str,
|
||||
command_description: str,
|
||||
command_id: str,
|
||||
kwargs: dict):
|
||||
self._manager = manager
|
||||
self._command_name = command_name
|
||||
self._command_description = command_description
|
||||
self._command_id = command_id
|
||||
self._kwargs = kwargs
|
||||
self._trace: Optional[ProfilingTrace] = None
|
||||
self._span: Optional[ProfilingSpan] = None
|
||||
self._token = None
|
||||
|
||||
def __enter__(self) -> ProfilingSpan:
|
||||
self._trace = ProfilingTrace(
|
||||
command_name=self._command_name,
|
||||
command_description=self._command_description,
|
||||
command_id=self._command_id,
|
||||
kwargs=dict(self._kwargs) if self._kwargs else {},
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
self._span = ProfilingSpan(name=self._command_name)
|
||||
self._token = self._manager.push_span(self._span)
|
||||
return self._span
|
||||
|
||||
def __exit__(self, *args):
|
||||
self._manager.pop_span(self._span, self._token)
|
||||
self._trace.root_span = self._span
|
||||
self._trace.total_duration_ms = self._span.duration_ms
|
||||
self._manager.add_trace(self._trace)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ProfilingManager:
|
||||
"""Global in-memory profiling manager.
|
||||
|
||||
All probe mechanisms check ``enabled`` at call time, so the profiler can
|
||||
be toggled without restarting the server. Use the module-level ``profiler``
|
||||
singleton rather than instantiating this class directly.
|
||||
"""
|
||||
|
||||
def __init__(self, max_traces: int = None):
|
||||
from myfasthtml.core.constants import PROFILER_MAX_TRACES
|
||||
self.enabled: bool = False
|
||||
self._traces: deque = deque(maxlen=max_traces or PROFILER_MAX_TRACES)
|
||||
self._current_span: ContextVar[Optional[ProfilingSpan]] = ContextVar(
|
||||
'profiler_current_span', default=None
|
||||
)
|
||||
self._overhead_samples: list = []
|
||||
|
||||
# --- Span lifecycle ---
|
||||
|
||||
def push_span(self, span: ProfilingSpan) -> object:
|
||||
"""Register a span as the current context and attach it to the parent.
|
||||
|
||||
Args:
|
||||
span: The span to activate.
|
||||
|
||||
Returns:
|
||||
A reset token to pass to pop_span().
|
||||
"""
|
||||
parent = self._current_span.get()
|
||||
if isinstance(parent, ProfilingSpan):
|
||||
parent.children.append(span)
|
||||
return self._current_span.set(span)
|
||||
|
||||
def pop_span(self, span: ProfilingSpan, token: object) -> None:
|
||||
"""Finish a span and restore the previous context.
|
||||
|
||||
Args:
|
||||
span: The span to finish.
|
||||
token: The reset token returned by push_span().
|
||||
"""
|
||||
span.finish()
|
||||
self._current_span.reset(token)
|
||||
|
||||
def add_trace(self, trace: ProfilingTrace) -> None:
|
||||
"""Add a completed trace to the buffer.
|
||||
|
||||
Args:
|
||||
trace: The trace to store.
|
||||
"""
|
||||
self._traces.appendleft(trace)
|
||||
|
||||
def record_overhead(self, duration_s: float) -> None:
|
||||
"""Record a span boundary overhead sample.
|
||||
|
||||
Args:
|
||||
duration_s: Duration in seconds of the profiler's own housekeeping.
|
||||
"""
|
||||
self._overhead_samples.append(duration_s * 1e6)
|
||||
if len(self._overhead_samples) > 1000:
|
||||
self._overhead_samples = self._overhead_samples[-1000:]
|
||||
|
||||
@staticmethod
|
||||
def capture_args(fn, args, kwargs) -> dict:
|
||||
"""Capture function arguments as a truncated string dict.
|
||||
|
||||
Args:
|
||||
fn: The function whose signature is inspected.
|
||||
args: Positional arguments passed to the function.
|
||||
kwargs: Keyword arguments passed to the function.
|
||||
|
||||
Returns:
|
||||
Dict of parameter names to string-truncated values.
|
||||
"""
|
||||
try:
|
||||
sig = inspect.signature(fn)
|
||||
bound = sig.bind(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
params = dict(bound.arguments)
|
||||
params.pop('self', None)
|
||||
params.pop('cls', None)
|
||||
return {k: str(v)[:200] for k, v in params.items()}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
# --- Public interface ---
|
||||
|
||||
@property
|
||||
def traces(self) -> list[ProfilingTrace]:
|
||||
"""All recorded traces, most recent first."""
|
||||
return list(self._traces)
|
||||
|
||||
def current_span(self) -> Optional[ProfilingSpan]:
|
||||
"""Return the active span in the current async context.
|
||||
|
||||
Can be called from anywhere within a span to attach metadata::
|
||||
|
||||
profiler.current_span().set("row_count", len(rows))
|
||||
"""
|
||||
return self._current_span.get()
|
||||
|
||||
def clear(self):
|
||||
"""Empty the trace buffer."""
|
||||
self._traces.clear()
|
||||
logger.debug("Profiler traces cleared.")
|
||||
|
||||
# --- Probe mechanisms ---
|
||||
|
||||
def span(self, name: str, args: dict = None) -> _ActiveSpan:
|
||||
"""Context manager and decorator for a named span.
|
||||
|
||||
The enabled check is deferred to call/enter time, so this can be used
|
||||
as a static decorator without concern for startup order.
|
||||
|
||||
Args:
|
||||
name: Span name.
|
||||
args: Optional metadata to attach immediately.
|
||||
|
||||
Returns:
|
||||
An object usable as a context manager or decorator.
|
||||
"""
|
||||
return _ActiveSpan(self, name, args)
|
||||
|
||||
def cumulative_span(self, name: str) -> _CumulativeActiveSpan:
|
||||
"""Context manager and decorator for loop spans.
|
||||
|
||||
Aggregates all iterations into a single entry (count, total, min, max, avg).
|
||||
|
||||
Args:
|
||||
name: Span name.
|
||||
|
||||
Returns:
|
||||
An object usable as a context manager or decorator.
|
||||
"""
|
||||
return _CumulativeActiveSpan(self, name)
|
||||
|
||||
def command_span(self,
|
||||
command_name: str,
|
||||
command_description: str,
|
||||
command_id: str,
|
||||
kwargs: dict) -> '_CommandSpan | _NullSpan':
|
||||
"""Context manager for the route handler.
|
||||
|
||||
Creates a ProfilingTrace and its root span together. When the context
|
||||
exits, the trace is added to the buffer.
|
||||
|
||||
Args:
|
||||
command_name: Human-readable command name.
|
||||
command_description: Human-readable description of the command.
|
||||
command_id: UUID string of the command.
|
||||
kwargs: Client arguments received by the route handler.
|
||||
|
||||
Returns:
|
||||
An object usable as a context manager.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return _NullSpan()
|
||||
return _CommandSpan(self, command_name, command_description, command_id, kwargs)
|
||||
|
||||
def trace_all(self, cls=None, *, exclude: list = None):
|
||||
"""Class decorator — statically wraps all non-dunder methods with spans.
|
||||
|
||||
Wrapping happens at class definition time; the enabled check is deferred
|
||||
to call time via _ActiveSpan.__call__.
|
||||
|
||||
Args:
|
||||
cls: The class to instrument (when used without parentheses).
|
||||
exclude: List of method names to skip.
|
||||
|
||||
Usage::
|
||||
|
||||
@profiler.trace_all
|
||||
class MyClass: ...
|
||||
|
||||
@profiler.trace_all(exclude=["render"])
|
||||
class MyClass: ...
|
||||
"""
|
||||
_exclude = set(exclude or [])
|
||||
|
||||
def decorator(klass):
|
||||
for attr_name, method in inspect.getmembers(klass, predicate=inspect.isfunction):
|
||||
if attr_name in _exclude:
|
||||
continue
|
||||
if attr_name.startswith('__') and attr_name.endswith('__'):
|
||||
continue
|
||||
setattr(klass, attr_name, _ActiveSpan(self, attr_name)(method))
|
||||
return klass
|
||||
|
||||
if cls is not None:
|
||||
return decorator(cls)
|
||||
return decorator
|
||||
|
||||
def trace_calls(self, fn):
|
||||
"""Function decorator — traces all sub-calls via sys.setprofile().
|
||||
|
||||
Use for exploration when the bottleneck location is unknown.
|
||||
sys.setprofile() is scoped to this function's execution only;
|
||||
the global profiler is restored on exit.
|
||||
|
||||
The root span for ``fn`` itself is created before setprofile is
|
||||
activated so that profiler internals are not captured as children.
|
||||
|
||||
Args:
|
||||
fn: The function to instrument.
|
||||
"""
|
||||
manager = self
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not manager.enabled:
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
call_stack: list[tuple[ProfilingSpan, object]] = []
|
||||
# Skip the first call event (fn itself — already represented by root_span)
|
||||
skip_first = [True]
|
||||
|
||||
def _profile(frame, event, arg):
|
||||
if event == 'call':
|
||||
if skip_first[0]:
|
||||
skip_first[0] = False
|
||||
return
|
||||
span = ProfilingSpan(name=frame.f_code.co_name)
|
||||
token = manager.push_span(span)
|
||||
call_stack.append((span, token))
|
||||
elif event in ('return', 'exception'):
|
||||
if call_stack:
|
||||
span, token = call_stack.pop()
|
||||
manager.pop_span(span, token)
|
||||
|
||||
# Build root span BEFORE activating setprofile so that profiler
|
||||
# internals (capture_args, ProfilingSpan.__init__, etc.) are not
|
||||
# captured as children.
|
||||
captured = manager.capture_args(fn, args, kwargs)
|
||||
root_span = ProfilingSpan(name=fn.__name__)
|
||||
root_span.data.update(captured)
|
||||
root_token = manager.push_span(root_span)
|
||||
|
||||
old_profile = sys.getprofile()
|
||||
sys.setprofile(_profile)
|
||||
try:
|
||||
result = fn(*args, **kwargs)
|
||||
finally:
|
||||
sys.setprofile(old_profile)
|
||||
manager.pop_span(root_span, root_token)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
# --- Overhead measurement ---
|
||||
|
||||
@property
|
||||
def overhead_per_span_us(self) -> float:
|
||||
"""Average overhead per span boundary in microseconds."""
|
||||
if not self._overhead_samples:
|
||||
return 0.0
|
||||
return sum(self._overhead_samples) / len(self._overhead_samples)
|
||||
|
||||
@property
|
||||
def total_overhead_ms(self) -> float:
|
||||
"""Estimated total overhead across all recorded traces."""
|
||||
total_spans = sum(
|
||||
self._count_spans(t.root_span) for t in self._traces if t.root_span
|
||||
)
|
||||
return (total_spans * self.overhead_per_span_us * 2) / 1000
|
||||
|
||||
def _count_spans(self, span: ProfilingSpan) -> int:
|
||||
if span is None:
|
||||
return 0
|
||||
count = 1
|
||||
for child in span.children:
|
||||
if isinstance(child, ProfilingSpan):
|
||||
count += self._count_spans(child)
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
profiler = ProfilingManager()
|
||||
@@ -11,6 +11,7 @@ from rich.table import Table
|
||||
from starlette.routing import Mount
|
||||
|
||||
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
||||
from myfasthtml.core.profiler import profiler
|
||||
from myfasthtml.core.dsl.exceptions import DSLSyntaxError
|
||||
from myfasthtml.core.dsl.types import Position
|
||||
from myfasthtml.core.dsls import DslsManager
|
||||
@@ -379,8 +380,9 @@ def post(session, c_id: str, client_response: dict = None):
|
||||
command = CommandsManager.get_command(c_id)
|
||||
if command:
|
||||
logger.debug(f"Executing command {command.name}.")
|
||||
return command.execute(client_response)
|
||||
|
||||
with profiler.command_span(command.name, command.description, c_id, client_response or {}):
|
||||
return command.execute(client_response)
|
||||
|
||||
raise ValueError(f"Command with ID '{c_id}' not found.")
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,12 @@ class EndsWith(AttrPredicate):
|
||||
|
||||
class Contains(AttrPredicate):
|
||||
def __init__(self, *value, _word=False):
|
||||
"""
|
||||
Initializes the instance with the given value and optional private attribute `_word`.
|
||||
|
||||
:param value:
|
||||
:param _word: Matches the entire word if True, otherwise matches any substring.
|
||||
"""
|
||||
super().__init__(value)
|
||||
self._word = _word
|
||||
|
||||
|
||||
@@ -281,14 +281,14 @@ class TestDataGridBehaviour:
|
||||
# Selection and Interaction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_i_can_on_key_pressed_esc_clears_selection(self, datagrid):
|
||||
def test_i_can_on_key_pressed_esc_clears_selection(self, datagrid_with_data):
|
||||
"""Test that pressing ESC resets both the focused cell and extra selections.
|
||||
|
||||
ESC is the standard 'deselect all' shortcut. Both selected and
|
||||
extra_selected must be cleared so the grid visually deselects everything
|
||||
and subsequent navigation starts from a clean state.
|
||||
"""
|
||||
dg = datagrid
|
||||
dg = datagrid_with_data
|
||||
dg._state.selection.selected = (1, 2)
|
||||
dg._state.selection.extra_selected.append(("range", (0, 0, 2, 2)))
|
||||
|
||||
|
||||
255
tests/controls/test_profiler.py
Normal file
255
tests/controls/test_profiler.py
Normal file
@@ -0,0 +1,255 @@
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fasthtml.common import Div, Span
|
||||
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.Profiler import Profiler
|
||||
from myfasthtml.core.instances import InstancesManager
|
||||
from myfasthtml.core.profiler import profiler, ProfilingTrace
|
||||
from myfasthtml.test.matcher import matches, find, Contains, TestIcon, DoesNotContain, And, TestObject
|
||||
|
||||
|
||||
def make_trace(
|
||||
command_name: str = "TestCommand",
|
||||
duration_ms: float = 50.0,
|
||||
trace_id: str = None,
|
||||
) -> ProfilingTrace:
|
||||
"""Create a fake ProfilingTrace for testing purposes."""
|
||||
return ProfilingTrace(
|
||||
command_name=command_name,
|
||||
command_description=f"{command_name} description",
|
||||
command_id=str(uuid4()),
|
||||
kwargs={},
|
||||
timestamp=datetime.now(),
|
||||
total_duration_ms=duration_ms,
|
||||
trace_id=trace_id or str(uuid4()),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_profiler():
|
||||
"""Reset profiler singleton state before and after each test."""
|
||||
profiler.clear()
|
||||
profiler.enabled = False
|
||||
yield
|
||||
profiler.clear()
|
||||
profiler.enabled = False
|
||||
|
||||
|
||||
class TestProfilerBehaviour:
|
||||
"""Tests for Profiler control behavior and logic."""
|
||||
|
||||
@pytest.fixture
|
||||
def profiler_control(self, root_instance):
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
ctrl = Profiler(root_instance)
|
||||
yield ctrl
|
||||
InstancesManager.reset()
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
|
||||
def test_i_can_create_profiler(self, profiler_control):
|
||||
"""Test that Profiler initializes with no trace selected."""
|
||||
assert profiler_control._selected_id is None
|
||||
|
||||
@pytest.mark.parametrize("initial", [
|
||||
False,
|
||||
True,
|
||||
])
|
||||
def test_i_can_toggle_enable(self, profiler_control, initial):
|
||||
"""Test that handle_toggle_enable inverts profiler.enabled."""
|
||||
profiler.enabled = initial
|
||||
profiler_control.handle_toggle_enable()
|
||||
assert profiler.enabled == (not initial)
|
||||
|
||||
def test_i_can_add_traces(self, profiler_control):
|
||||
trace_a = make_trace("CommandA", 30.0)
|
||||
trace_b = make_trace("CommandB", 60.0)
|
||||
profiler._traces.appendleft(trace_a)
|
||||
profiler._traces.appendleft(trace_b)
|
||||
|
||||
assert len(profiler.traces) == 2
|
||||
assert profiler.traces == [trace_b, trace_a]
|
||||
|
||||
def test_i_can_clear_traces_via_handler(self, profiler_control):
|
||||
"""Test that handle_clear_traces empties the profiler trace buffer."""
|
||||
profiler._traces.appendleft(make_trace())
|
||||
profiler_control.handle_clear_traces()
|
||||
assert len(profiler.traces) == 0
|
||||
|
||||
def test_i_can_select_trace_by_id(self, profiler_control):
|
||||
"""Test that handle_select_trace stores the given trace_id."""
|
||||
trace_id = str(uuid4())
|
||||
profiler_control.handle_select_trace(trace_id)
|
||||
assert profiler_control._selected_id == trace_id
|
||||
|
||||
def test_i_can_select_trace_stable_when_new_trace_added(self, profiler_control):
|
||||
"""Test that selection by trace_id remains correct when a new trace is prepended.
|
||||
|
||||
This validates the fix for the index-shift bug: adding a new trace (appendleft)
|
||||
must not affect which row appears selected.
|
||||
"""
|
||||
trace_a = make_trace("CommandA", 30.0)
|
||||
trace_b = make_trace("CommandB", 60.0)
|
||||
profiler._traces.appendleft(trace_a)
|
||||
profiler._traces.appendleft(trace_b)
|
||||
profiler_control.handle_select_trace(trace_a.trace_id)
|
||||
|
||||
# Add a new trace (simulates a new command executing after selection)
|
||||
profiler._traces.appendleft(make_trace("NewCommand", 10.0))
|
||||
|
||||
# Selection still points to trace_a, unaffected by the new prepended trace
|
||||
assert profiler_control._selected_id == trace_a.trace_id
|
||||
|
||||
@pytest.mark.parametrize("duration_ms, expected_cls", [
|
||||
(10.0, "mf-profiler-fast"),
|
||||
(50.0, "mf-profiler-medium"),
|
||||
(150.0, "mf-profiler-slow"),
|
||||
])
|
||||
def test_i_can_get_duration_class(self, profiler_control, duration_ms, expected_cls):
|
||||
"""Test that _duration_cls returns the correct CSS class for each threshold."""
|
||||
assert profiler_control._duration_cls(duration_ms) == expected_cls
|
||||
|
||||
|
||||
class TestProfilerRender:
|
||||
"""Tests for Profiler control HTML rendering."""
|
||||
|
||||
@pytest.fixture
|
||||
def profiler_control(self, root_instance):
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
ctrl = Profiler(root_instance)
|
||||
yield ctrl
|
||||
InstancesManager.reset()
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
|
||||
def test_profiler_renders_global_structure(self, profiler_control):
|
||||
"""Test that Profiler renders with correct global structure.
|
||||
|
||||
Why these elements matter:
|
||||
- id: Required for HTMX targeting (all commands target this id)
|
||||
- cls Contains "mf-profiler": Root CSS class for layout and styling
|
||||
- toolbar Div: Always present, contains control actions
|
||||
- Panel: Always present, hosts trace list and detail panels
|
||||
"""
|
||||
html = profiler_control.render()
|
||||
expected = Div(
|
||||
Div(cls=Contains("mf-profiler-toolbar")), # toolbar
|
||||
TestObject(Panel), # panel
|
||||
id=profiler_control.get_id(),
|
||||
cls=Contains("mf-profiler"),
|
||||
)
|
||||
assert matches(html, expected)
|
||||
|
||||
def test_i_can_render_toolbar_when_enabled(self, profiler_control):
|
||||
"""Test that toolbar shows pause icon when profiler is enabled.
|
||||
|
||||
Why these elements matter:
|
||||
- pause icon: visual indicator that profiler is actively recording
|
||||
"""
|
||||
profiler.enabled = True
|
||||
toolbar = profiler_control._mk_toolbar()
|
||||
assert matches(toolbar, Div(TestIcon("pause_circle20_regular")))
|
||||
|
||||
def test_i_can_render_toolbar_when_disabled(self, profiler_control):
|
||||
"""Test that toolbar shows play icon when profiler is disabled.
|
||||
|
||||
Why these elements matter:
|
||||
- play icon: visual indicator that profiler is stopped and ready to record
|
||||
"""
|
||||
profiler.enabled = False
|
||||
toolbar = profiler_control._mk_toolbar()
|
||||
assert matches(toolbar, Div(TestIcon("play_circle20_regular")))
|
||||
|
||||
def test_i_can_render_toolbar_clear_button(self, profiler_control):
|
||||
"""Test that toolbar contains exactly one danger-styled clear button.
|
||||
|
||||
Why these elements matter:
|
||||
- cls Contains "mf-profiler-btn-danger": Ensures the clear button is visually
|
||||
distinct (red) to warn the user before clearing all traces
|
||||
"""
|
||||
toolbar = profiler_control._mk_toolbar()
|
||||
danger_buttons = find(toolbar, Div(cls=Contains("mf-profiler-btn-danger")))
|
||||
assert len(danger_buttons) == 1, "Toolbar should contain exactly one danger-styled button"
|
||||
|
||||
def test_i_can_render_empty_trace_list(self, profiler_control):
|
||||
"""Test that an empty-state message is shown when no traces are recorded.
|
||||
|
||||
Why these elements matter:
|
||||
- "No traces recorded.": User-facing feedback when profiler has no data
|
||||
- cls Contains "mf-profiler-empty": Applies centered empty-state styling
|
||||
"""
|
||||
trace_list = profiler_control._mk_trace_list()
|
||||
assert matches(trace_list, Div("No traces recorded.", cls=Contains("mf-profiler-empty")))
|
||||
|
||||
def test_i_can_render_trace_with_name_and_timestamp(self, profiler_control):
|
||||
"""Test that a trace row shows command name and formatted timestamp.
|
||||
|
||||
Why these elements matter:
|
||||
- Span.mf-profiler-cmd with command_name: primary identifier for the user
|
||||
- Span.mf-profiler-ts with formatted timestamp: helps correlate traces with events
|
||||
"""
|
||||
trace = make_trace("NavigateCell", 50.0)
|
||||
ts_expected = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
|
||||
profiler._traces.appendleft(trace)
|
||||
|
||||
trace_list = profiler_control._mk_trace_list()
|
||||
|
||||
cmd_spans = find(trace_list, Span("NavigateCell", cls=Contains("mf-profiler-cmd", _word=True)))
|
||||
assert len(cmd_spans) == 1, "Command name should appear exactly once in the trace list"
|
||||
|
||||
ts_spans = find(trace_list, Span(ts_expected, cls=Contains("mf-profiler-ts")))
|
||||
assert len(ts_spans) == 1, "Formatted timestamp should appear exactly once in the trace list"
|
||||
|
||||
def test_i_can_render_selected_row_has_selected_class(self, profiler_control):
|
||||
"""Test that the selected row carries the selected CSS class.
|
||||
|
||||
Why these elements matter:
|
||||
- cls Contains "mf-profiler-row-selected": Visual highlight of the active trace,
|
||||
matched by trace_id (stable identifier) rather than list index
|
||||
"""
|
||||
trace = make_trace("NavigateCell", 50.0)
|
||||
profiler._traces.appendleft(trace)
|
||||
profiler_control._selected_id = trace.trace_id
|
||||
|
||||
trace_list = profiler_control._mk_trace_list()
|
||||
selected_rows = find(trace_list, Div(cls=Contains("mf-profiler-row-selected")))
|
||||
assert len(selected_rows) == 1, "Exactly one row should carry the selected class"
|
||||
|
||||
def test_i_can_render_unselected_row_has_no_selected_class(self, profiler_control):
|
||||
"""Test that non-selected rows do not carry the selected CSS class.
|
||||
|
||||
Why these elements matter:
|
||||
- cls And(Contains("mf-profiler-row"), DoesNotContain("mf-profiler-row-selected")):
|
||||
Confirms that only the selected trace is highlighted, not its siblings
|
||||
"""
|
||||
trace_a = make_trace("CommandA", 30.0)
|
||||
trace_b = make_trace("CommandB", 80.0)
|
||||
profiler._traces.appendleft(trace_a)
|
||||
profiler._traces.appendleft(trace_b)
|
||||
profiler_control._selected_id = trace_b.trace_id
|
||||
|
||||
trace_list = profiler_control._mk_trace_list()
|
||||
unselected_rows = find(trace_list,
|
||||
Div(cls=And(Contains("mf-profiler-row"), DoesNotContain("mf-profiler-row-selected"))))
|
||||
assert len(unselected_rows) == 1, "Exactly one row should remain unselected"
|
||||
|
||||
@pytest.mark.parametrize("duration_ms, expected_cls", [
|
||||
(10.0, "mf-profiler-fast"),
|
||||
(50.0, "mf-profiler-medium"),
|
||||
(150.0, "mf-profiler-slow"),
|
||||
])
|
||||
def test_i_can_render_duration_color_class(self, profiler_control, duration_ms, expected_cls):
|
||||
"""Test that the duration span carries the correct color class per threshold.
|
||||
|
||||
Why these elements matter:
|
||||
- mf-profiler-fast/medium/slow on the duration Span: color-codes performance
|
||||
at a glance, consistent with the thresholds defined in _duration_cls()
|
||||
"""
|
||||
trace = make_trace("TestCommand", duration_ms)
|
||||
profiler._traces.appendleft(trace)
|
||||
|
||||
trace_list = profiler_control._mk_trace_list()
|
||||
duration_spans = find(trace_list, Span(cls=Contains(expected_cls)))
|
||||
assert len(duration_spans) == 1, f"Expected exactly one span with class '{expected_cls}'"
|
||||
@@ -41,4 +41,4 @@ def db_manager(parent):
|
||||
|
||||
@pytest.fixture
|
||||
def dsm(parent, db_manager):
|
||||
return DataServicesManager(parent, parent._session)
|
||||
return DataServicesManager(parent)
|
||||
|
||||
678
tests/core/test_profiler.py
Normal file
678
tests/core/test_profiler.py
Normal file
@@ -0,0 +1,678 @@
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.profiler import (
|
||||
ProfilingManager,
|
||||
ProfilingSpan,
|
||||
CumulativeSpan,
|
||||
ProfilingTrace,
|
||||
_NullSpan,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_profiler():
|
||||
"""Provide a fresh ProfilingManager for each test."""
|
||||
p = ProfilingManager(max_traces=10)
|
||||
p.enabled = True
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestProfilingModels — data model tests (ProfilingSpan, CumulativeSpan)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProfilingModels:
|
||||
|
||||
def test_i_can_compute_avg_ms_when_no_iterations(self):
|
||||
"""Test that avg_ms returns 0.0 on a fresh CumulativeSpan to avoid division by zero."""
|
||||
cum = CumulativeSpan(name="empty")
|
||||
assert cum.avg_ms == 0.0
|
||||
assert cum.total_ms == 0.0
|
||||
assert cum.count == 0
|
||||
assert cum.min_ms == float('inf')
|
||||
assert cum.max_ms == 0
|
||||
|
||||
@pytest.mark.parametrize("durations, expected_min, expected_max, expected_total, expected_avg", [
|
||||
([10.0, 5.0, 8.0], 5.0, 10.0, 23.0, 23.0 / 3),
|
||||
([1.0], 1.0, 1.0, 1.0, 1.0),
|
||||
([3.0, 3.0, 3.0], 3.0, 3.0, 9.0, 3.0),
|
||||
])
|
||||
def test_i_can_aggregate_iterations_in_cumulative_span(
|
||||
self, durations, expected_min, expected_max, expected_total, expected_avg
|
||||
):
|
||||
"""Test that CumulativeSpan correctly aggregates all metrics across iterations."""
|
||||
cum = CumulativeSpan(name="probe")
|
||||
for d in durations:
|
||||
cum.record(d)
|
||||
|
||||
assert cum.count == len(durations)
|
||||
assert cum.min_ms == expected_min
|
||||
assert cum.max_ms == expected_max
|
||||
assert cum.total_ms == expected_total
|
||||
assert cum.avg_ms == pytest.approx(expected_avg)
|
||||
|
||||
def test_i_can_chain_set_calls_on_span(self, fresh_profiler):
|
||||
"""Test that set() returns self to allow fluent chaining."""
|
||||
p = fresh_profiler
|
||||
with p.span("query") as s:
|
||||
result = s.set("table", "orders").set("rows", 42)
|
||||
|
||||
assert result is s
|
||||
assert s.data["table"] == "orders"
|
||||
assert s.data["rows"] == 42
|
||||
|
||||
def test_i_can_access_span_data_after_context_exits(self, fresh_profiler):
|
||||
"""Test that span data and duration persist after the with block has exited."""
|
||||
p = fresh_profiler
|
||||
with p.span("query") as s:
|
||||
s.set("key", "value")
|
||||
|
||||
assert s.data["key"] == "value"
|
||||
assert s.duration_ms > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSpan — profiler.span() context manager and decorator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSpan:
|
||||
|
||||
def test_i_can_create_a_span(self, fresh_profiler):
|
||||
"""Test that a span is created with correct name and measured duration."""
|
||||
p = fresh_profiler
|
||||
with p.span("my_span") as s:
|
||||
time.sleep(0.01)
|
||||
|
||||
assert isinstance(s, ProfilingSpan)
|
||||
assert s.name == "my_span"
|
||||
assert s.duration_ms >= 10
|
||||
|
||||
def test_i_can_nest_spans(self, fresh_profiler):
|
||||
"""Test that a span created inside another becomes its child."""
|
||||
p = fresh_profiler
|
||||
with p.span("parent") as parent:
|
||||
with p.span("child") as child:
|
||||
pass
|
||||
|
||||
assert len(parent.children) == 1
|
||||
assert parent.children[0] is child
|
||||
assert child.name == "child"
|
||||
|
||||
def test_i_can_have_sibling_spans(self, fresh_profiler):
|
||||
"""Test that consecutive spans under the same parent are recorded as siblings."""
|
||||
p = fresh_profiler
|
||||
with p.span("parent") as parent:
|
||||
with p.span("child_a"):
|
||||
pass
|
||||
with p.span("child_b"):
|
||||
pass
|
||||
|
||||
assert len(parent.children) == 2
|
||||
assert parent.children[0].name == "child_a"
|
||||
assert parent.children[1].name == "child_b"
|
||||
|
||||
def test_i_can_create_two_spans_with_same_name_under_same_parent(self, fresh_profiler):
|
||||
"""Test that two normal spans with the same name under the same parent are two distinct entries.
|
||||
|
||||
This contrasts with cumulative spans, which merge same-name entries into one aggregated entry.
|
||||
"""
|
||||
p = fresh_profiler
|
||||
with p.span("parent") as parent:
|
||||
with p.span("work"):
|
||||
pass
|
||||
with p.span("work"):
|
||||
pass
|
||||
|
||||
assert len(parent.children) == 2, "Normal spans with the same name must remain separate entries"
|
||||
assert parent.children[0] is not parent.children[1]
|
||||
|
||||
def test_i_can_use_same_span_name_under_different_parents(self, fresh_profiler):
|
||||
"""Test that spans with the same name under different parents are independent objects."""
|
||||
p = fresh_profiler
|
||||
with p.span("root"):
|
||||
with p.span("parent_a") as parent_a:
|
||||
with p.span("work"):
|
||||
pass
|
||||
with p.span("parent_b") as parent_b:
|
||||
with p.span("work"):
|
||||
pass
|
||||
|
||||
span_a = parent_a.children[0]
|
||||
span_b = parent_b.children[0]
|
||||
assert span_a is not span_b, "Same name under different parents must be separate objects"
|
||||
|
||||
def test_i_can_use_span_as_decorator(self, fresh_profiler):
|
||||
"""Test that @span wraps a function and captures all positional arguments."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.span("decorated")
|
||||
def my_func(x, y):
|
||||
return x + y
|
||||
|
||||
with p.span("root") as root:
|
||||
result = my_func(1, 2)
|
||||
|
||||
assert result == 3
|
||||
assert len(root.children) == 1
|
||||
child = root.children[0]
|
||||
assert child.name == "decorated"
|
||||
assert child.data.get("x") == "1"
|
||||
assert child.data.get("y") == "2"
|
||||
|
||||
def test_i_can_capture_all_args_with_span_decorator(self, fresh_profiler):
|
||||
"""Test that all positional and keyword arguments are captured by the @span decorator."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.span("compute")
|
||||
def my_func(x, y, z=10):
|
||||
return x + y + z
|
||||
|
||||
with p.span("root") as root:
|
||||
my_func(1, 2, z=3)
|
||||
|
||||
child = root.children[0]
|
||||
assert child.data.get("x") == "1"
|
||||
assert child.data.get("y") == "2"
|
||||
assert child.data.get("z") == "3"
|
||||
|
||||
def test_i_can_exclude_self_from_captured_args_with_span_decorator(self, fresh_profiler):
|
||||
"""Test that 'self' is not included in the captured args of a decorated method."""
|
||||
p = fresh_profiler
|
||||
|
||||
class MyClass:
|
||||
@p.span("method")
|
||||
def my_method(self, value):
|
||||
return value
|
||||
|
||||
with p.span("root") as root:
|
||||
MyClass().my_method(42)
|
||||
|
||||
child = root.children[0]
|
||||
assert "self" not in child.data
|
||||
assert child.data.get("value") == "42"
|
||||
|
||||
def test_i_can_use_span_decorator_without_parent(self, fresh_profiler):
|
||||
"""Test that a decorated function runs correctly with no active parent span."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.span("solo")
|
||||
def my_func():
|
||||
return 42
|
||||
|
||||
result = my_func()
|
||||
assert result == 42
|
||||
|
||||
def test_i_can_attach_data_to_span_via_context_manager(self, fresh_profiler):
|
||||
"""Test that metadata can be attached to a span inside the with block."""
|
||||
p = fresh_profiler
|
||||
with p.span("query") as s:
|
||||
s.set("row_count", 42)
|
||||
s.set("table", "users")
|
||||
|
||||
assert s.data["row_count"] == 42
|
||||
assert s.data["table"] == "users"
|
||||
|
||||
def test_i_can_attach_data_via_current_span(self, fresh_profiler):
|
||||
"""Test that current_span().set() attaches metadata to the active span from anywhere."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.span("process")
|
||||
def process():
|
||||
p.current_span().set("result", "ok")
|
||||
|
||||
with p.span("root") as root:
|
||||
process()
|
||||
|
||||
child = root.children[0]
|
||||
assert child.data["result"] == "ok"
|
||||
|
||||
def test_i_can_pass_args_to_span_context_manager(self, fresh_profiler):
|
||||
"""Test that metadata can be pre-attached to a span via the args parameter."""
|
||||
p = fresh_profiler
|
||||
with p.span("query", args={"table": "orders"}) as s:
|
||||
pass
|
||||
|
||||
assert s.data["table"] == "orders"
|
||||
|
||||
def test_i_can_get_none_from_current_span_when_no_span_active(self, fresh_profiler):
|
||||
"""Test that current_span() returns None when called outside any span context."""
|
||||
p = fresh_profiler
|
||||
assert p.current_span() is None
|
||||
|
||||
def test_i_can_get_innermost_span_via_current_span(self, fresh_profiler):
|
||||
"""Test that current_span() returns the innermost active span in nested contexts."""
|
||||
p = fresh_profiler
|
||||
with p.span("outer"):
|
||||
with p.span("inner") as inner:
|
||||
assert p.current_span() is inner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCumulativeSpan — profiler.cumulative_span() context manager and decorator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCumulativeSpan:
|
||||
|
||||
def test_i_can_use_cumulative_span(self, fresh_profiler):
|
||||
"""Test that repeated iterations are aggregated into a single child entry."""
|
||||
p = fresh_profiler
|
||||
with p.span("loop") as loop_span:
|
||||
for _ in range(5):
|
||||
with p.cumulative_span("item"):
|
||||
time.sleep(0.001)
|
||||
|
||||
assert len(loop_span.children) == 1
|
||||
cum = loop_span.children[0]
|
||||
assert isinstance(cum, CumulativeSpan)
|
||||
assert cum.count == 5
|
||||
assert cum.total_ms >= 5
|
||||
assert cum.min_ms <= cum.avg_ms <= cum.max_ms
|
||||
|
||||
def test_i_can_use_cumulative_span_as_decorator(self, fresh_profiler):
|
||||
"""Test that @cumulative_span aggregates all decorated function calls."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.cumulative_span("item")
|
||||
def process_item(x):
|
||||
return x * 2
|
||||
|
||||
with p.span("loop") as loop_span:
|
||||
for i in range(3):
|
||||
process_item(i)
|
||||
|
||||
assert len(loop_span.children) == 1
|
||||
cum = loop_span.children[0]
|
||||
assert cum.count == 3
|
||||
|
||||
def test_i_can_continue_using_the_cumulative_span(self, fresh_profiler):
|
||||
"""Test that the same cumulative span accumulates across separate loop blocks."""
|
||||
p = fresh_profiler
|
||||
with p.span("parent") as parent:
|
||||
for _ in range(3):
|
||||
with p.cumulative_span("reads"):
|
||||
pass
|
||||
for _ in range(2):
|
||||
with p.cumulative_span("reads"):
|
||||
pass
|
||||
|
||||
assert len(parent.children) == 1
|
||||
reads = parent.children[0]
|
||||
assert reads.count == 5
|
||||
|
||||
def test_i_can_have_two_cumulative_spans_with_different_names(self, fresh_profiler):
|
||||
"""Test that two cumulative spans with different names create separate entries in the parent."""
|
||||
p = fresh_profiler
|
||||
with p.span("parent") as parent:
|
||||
for _ in range(3):
|
||||
with p.cumulative_span("reads"):
|
||||
pass
|
||||
for _ in range(2):
|
||||
with p.cumulative_span("writes"):
|
||||
pass
|
||||
|
||||
assert len(parent.children) == 2
|
||||
reads = next(c for c in parent.children if c.name == "reads")
|
||||
writes = next(c for c in parent.children if c.name == "writes")
|
||||
assert reads.count == 3
|
||||
assert writes.count == 2
|
||||
|
||||
def test_i_can_use_same_cumulative_span_name_under_different_parents(self, fresh_profiler):
|
||||
"""Test that cumulative spans with the same name under different parents are independent."""
|
||||
p = fresh_profiler
|
||||
with p.span("root"):
|
||||
with p.span("parent_a") as parent_a:
|
||||
for _ in range(3):
|
||||
with p.cumulative_span("items"):
|
||||
pass
|
||||
with p.span("parent_b") as parent_b:
|
||||
for _ in range(2):
|
||||
with p.cumulative_span("items"):
|
||||
pass
|
||||
|
||||
items_a = parent_a.children[0]
|
||||
items_b = parent_b.children[0]
|
||||
assert items_a is not items_b, "Same name under different parents must be separate objects"
|
||||
assert items_a.count == 3
|
||||
assert items_b.count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCommandSpan — profiler.command_span() and trace recording
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCommandSpan:
|
||||
|
||||
def test_i_can_record_a_trace_via_command_span(self, fresh_profiler):
|
||||
"""Test that command_span creates a complete trace with root span and children."""
|
||||
p = fresh_profiler
|
||||
with p.command_span("NavigateCell", "", "abc-123", {"row": "5"}):
|
||||
with p.span("callback"):
|
||||
time.sleep(0.01)
|
||||
|
||||
assert len(p.traces) == 1
|
||||
trace = p.traces[0]
|
||||
assert isinstance(trace, ProfilingTrace)
|
||||
assert trace.command_name == "NavigateCell"
|
||||
assert trace.command_id == "abc-123"
|
||||
assert trace.kwargs == {"row": "5"}
|
||||
assert trace.total_duration_ms >= 10
|
||||
assert len(trace.root_span.children) == 1
|
||||
assert trace.root_span.children[0].name == "callback"
|
||||
|
||||
def test_i_cannot_record_trace_when_profiler_disabled(self, fresh_profiler):
|
||||
"""Test that command_span is a no-op when the profiler is disabled."""
|
||||
p = fresh_profiler
|
||||
p.enabled = False
|
||||
with p.command_span("cmd", "", "id", {}):
|
||||
pass
|
||||
|
||||
assert len(p.traces) == 0
|
||||
|
||||
def test_i_can_record_up_to_max_traces(self, fresh_profiler):
|
||||
"""Test that the trace buffer respects the max_traces limit (FIFO eviction)."""
|
||||
p = fresh_profiler
|
||||
for i in range(15):
|
||||
with p.command_span(f"cmd_{i}", "", str(i), {}):
|
||||
pass
|
||||
|
||||
assert len(p.traces) == 10, "Buffer should cap at max_traces=10"
|
||||
|
||||
def test_i_can_access_trace_timestamp(self, fresh_profiler):
|
||||
"""Test that a recorded trace contains a valid datetime timestamp."""
|
||||
p = fresh_profiler
|
||||
before = datetime.now()
|
||||
with p.command_span("MyCmd", "", "id-001", {}):
|
||||
pass
|
||||
after = datetime.now()
|
||||
|
||||
trace = p.traces[0]
|
||||
assert before <= trace.timestamp <= after
|
||||
|
||||
def test_i_can_verify_kwargs_are_copied_in_command_span(self, fresh_profiler):
|
||||
"""Test that mutating the original kwargs dict does not affect the recorded trace."""
|
||||
p = fresh_profiler
|
||||
kwargs = {"row": "5", "col": "2"}
|
||||
with p.command_span("MyCmd", "", "id-001", kwargs):
|
||||
pass
|
||||
|
||||
kwargs["row"] = "99"
|
||||
trace = p.traces[0]
|
||||
assert trace.kwargs["row"] == "5"
|
||||
|
||||
def test_i_can_record_command_description_in_trace(self, fresh_profiler):
|
||||
"""Test that the command description passed to command_span is stored in the trace."""
|
||||
p = fresh_profiler
|
||||
with p.command_span("NavigateCell", "Navigate to adjacent cell", "abc-123", {}):
|
||||
pass
|
||||
|
||||
trace = p.traces[0]
|
||||
assert trace.command_description == "Navigate to adjacent cell"
|
||||
|
||||
def test_i_can_record_empty_description_in_trace(self, fresh_profiler):
|
||||
"""Test that an empty description is stored as-is in the trace."""
|
||||
p = fresh_profiler
|
||||
with p.command_span("MyCmd", "", "id-001", {}):
|
||||
pass
|
||||
|
||||
trace = p.traces[0]
|
||||
assert trace.command_description == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTraceAll — profiler.trace_all() class decorator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTraceAll:
|
||||
|
||||
def test_i_can_use_trace_all_on_class(self, fresh_profiler):
|
||||
"""Test that trace_all wraps all non-dunder methods of a class."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.trace_all
|
||||
class MyClass:
|
||||
def method_a(self):
|
||||
return "a"
|
||||
|
||||
def method_b(self):
|
||||
return "b"
|
||||
|
||||
def __repr__(self):
|
||||
return "MyClass()"
|
||||
|
||||
obj = MyClass()
|
||||
with p.span("root") as root:
|
||||
obj.method_a()
|
||||
obj.method_b()
|
||||
|
||||
assert len(root.children) == 2
|
||||
assert root.children[0].name == "method_a"
|
||||
assert root.children[1].name == "method_b"
|
||||
|
||||
def test_i_can_use_trace_all_with_exclude(self, fresh_profiler):
|
||||
"""Test that excluded methods are not wrapped by trace_all."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.trace_all(exclude=["method_b"])
|
||||
class MyClass:
|
||||
def method_a(self):
|
||||
return "a"
|
||||
|
||||
def method_b(self):
|
||||
return "b"
|
||||
|
||||
obj = MyClass()
|
||||
with p.span("root") as root:
|
||||
obj.method_a()
|
||||
obj.method_b()
|
||||
|
||||
assert len(root.children) == 1
|
||||
assert root.children[0].name == "method_a"
|
||||
|
||||
def test_i_can_confirm_trace_all_skips_dunder_methods(self, fresh_profiler):
|
||||
"""Test that trace_all does not wrap dunder methods like __repr__."""
|
||||
p = fresh_profiler
|
||||
call_log = []
|
||||
|
||||
@p.trace_all
|
||||
class MyClass:
|
||||
def __repr__(self):
|
||||
call_log.append("repr")
|
||||
return "MyClass()"
|
||||
|
||||
def method_a(self):
|
||||
return "a"
|
||||
|
||||
obj = MyClass()
|
||||
with p.span("root") as root:
|
||||
repr(obj)
|
||||
obj.method_a()
|
||||
|
||||
child_names = [c.name for c in root.children]
|
||||
assert "method_a" in child_names
|
||||
assert "__repr__" not in child_names
|
||||
assert "repr" in call_log
|
||||
|
||||
def test_i_can_use_trace_all_with_parentheses_and_no_exclude(self, fresh_profiler):
|
||||
"""Test that @profiler.trace_all() with parentheses and no args behaves like @profiler.trace_all."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.trace_all()
|
||||
class MyClass:
|
||||
def method_a(self):
|
||||
return "a"
|
||||
|
||||
obj = MyClass()
|
||||
with p.span("root") as root:
|
||||
obj.method_a()
|
||||
|
||||
assert len(root.children) == 1
|
||||
assert root.children[0].name == "method_a"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestTraceCalls — profiler.trace_calls() function decorator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTraceCalls:
|
||||
|
||||
def test_i_can_use_trace_calls_on_function(self, fresh_profiler):
|
||||
"""Test that trace_calls traces all sub-calls as children of the decorated function span.
|
||||
|
||||
Verifies both direct children (helper_a, helper_b under main_func) and nested hierarchy
|
||||
(grandchild under helper_a, not under main_func).
|
||||
"""
|
||||
p = fresh_profiler
|
||||
|
||||
def grandchild():
|
||||
return 0
|
||||
|
||||
def helper_a():
|
||||
grandchild()
|
||||
return 1
|
||||
|
||||
def helper_b():
|
||||
return 2
|
||||
|
||||
@p.trace_calls
|
||||
def main_func():
|
||||
helper_a()
|
||||
helper_b()
|
||||
return 42
|
||||
|
||||
with p.span("root") as root:
|
||||
main_func()
|
||||
|
||||
assert len(root.children) == 1
|
||||
main_span = root.children[0]
|
||||
assert main_span.name == "main_func"
|
||||
assert len(main_span.children) == 2, "main_func should have exactly 2 direct children"
|
||||
child_names = [c.name for c in main_span.children]
|
||||
assert "helper_a" in child_names
|
||||
assert "helper_b" in child_names
|
||||
|
||||
helper_a_span = next(c for c in main_span.children if c.name == "helper_a")
|
||||
assert len(helper_a_span.children) == 1, "grandchild must be nested under helper_a, not main_func"
|
||||
assert helper_a_span.children[0].name == "grandchild"
|
||||
|
||||
def test_i_cannot_use_trace_calls_when_disabled(self, fresh_profiler):
|
||||
"""Test that trace_calls creates no spans when the profiler is disabled at call time."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.trace_calls
|
||||
def main_func():
|
||||
return 99
|
||||
|
||||
with p.span("root") as root:
|
||||
p.enabled = False
|
||||
main_func()
|
||||
p.enabled = True
|
||||
|
||||
assert len(root.children) == 0, "trace_calls should not create spans when profiler is disabled"
|
||||
|
||||
def test_i_can_verify_trace_calls_captures_function_args(self, fresh_profiler):
|
||||
"""Test that trace_calls captures the decorated function's arguments in the root span data."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.trace_calls
|
||||
def compute(x, y):
|
||||
return x + y
|
||||
|
||||
with p.span("root") as root:
|
||||
compute(3, 7)
|
||||
|
||||
main_span = root.children[0]
|
||||
assert main_span.name == "compute"
|
||||
assert main_span.data.get("x") == "3"
|
||||
assert main_span.data.get("y") == "7"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestProfilingManager — enable/disable, clear, overhead
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProfilingManager:
|
||||
|
||||
def test_i_can_enable_disable_profiler(self, fresh_profiler):
|
||||
"""Test that disabling the profiler makes span() return a no-op NullSpan."""
|
||||
p = fresh_profiler
|
||||
p.enabled = False
|
||||
|
||||
with p.span("ignored") as s:
|
||||
pass
|
||||
|
||||
assert isinstance(s, _NullSpan)
|
||||
|
||||
def test_i_can_use_decorator_when_profiler_is_disabled(self, fresh_profiler):
|
||||
"""Test that a @span decorated function still executes correctly when profiler is disabled."""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.span("my_span")
|
||||
def my_func():
|
||||
return "result"
|
||||
|
||||
p.enabled = False
|
||||
result = my_func()
|
||||
assert result == "result"
|
||||
|
||||
def test_i_can_toggle_profiler_at_runtime(self, fresh_profiler):
|
||||
"""Test that spans are captured only while the profiler is enabled.
|
||||
|
||||
Three phases: enabled -> disabled -> re-enabled, verifying capture behavior at each step.
|
||||
"""
|
||||
p = fresh_profiler
|
||||
|
||||
@p.span("traced")
|
||||
def my_func():
|
||||
return 1
|
||||
|
||||
# Phase 1: enabled — span must be captured
|
||||
with p.span("root") as root:
|
||||
my_func()
|
||||
assert len(root.children) == 1, "Span should be captured when profiler is enabled"
|
||||
|
||||
# Phase 2: disabled — p.span() returns NullSpan, nothing captured
|
||||
p.enabled = False
|
||||
with p.span("root_disabled") as root_disabled:
|
||||
my_func()
|
||||
assert isinstance(root_disabled, _NullSpan), "p.span() should return NullSpan when disabled"
|
||||
|
||||
# Phase 3: re-enabled — span must be captured again
|
||||
p.enabled = True
|
||||
with p.span("root_reenabled") as root_reenabled:
|
||||
my_func()
|
||||
assert len(root_reenabled.children) == 1, "Span should be captured again after re-enabling"
|
||||
|
||||
def test_i_can_clear_traces(self, fresh_profiler):
|
||||
"""Test that clear() empties the trace buffer completely."""
|
||||
p = fresh_profiler
|
||||
with p.command_span("cmd", "", "uuid-1", {}):
|
||||
pass
|
||||
with p.command_span("cmd", "", "uuid-2", {}):
|
||||
pass
|
||||
|
||||
assert len(p.traces) == 2
|
||||
p.clear()
|
||||
assert len(p.traces) == 0
|
||||
|
||||
def test_i_can_measure_overhead(self, fresh_profiler):
|
||||
"""Test that overhead metrics are populated after spans are recorded."""
|
||||
p = fresh_profiler
|
||||
for _ in range(20):
|
||||
with p.span("probe"):
|
||||
pass
|
||||
|
||||
assert p.overhead_per_span_us >= 0
|
||||
assert p.total_overhead_ms >= 0
|
||||
|
||||
def test_i_can_get_zero_overhead_when_no_samples(self, fresh_profiler):
|
||||
"""Test that overhead_per_span_us returns 0.0 when no spans have been recorded."""
|
||||
p = fresh_profiler
|
||||
assert p.overhead_per_span_us == 0.0
|
||||
|
||||
def test_i_can_get_zero_total_overhead_when_buffer_empty(self, fresh_profiler):
|
||||
"""Test that total_overhead_ms returns 0.0 when the trace buffer is empty."""
|
||||
p = fresh_profiler
|
||||
assert p.total_overhead_ms == 0.0
|
||||
Reference in New Issue
Block a user