Added Profiler control with basic UI
This commit is contained in:
176
docs/Profiler.md
176
docs/Profiler.md
@@ -7,6 +7,23 @@ per command call). The HTMX debug traces (via `htmx_debug.js`) confirmed the bot
|
||||
server-side. A persistent, in-application profiling system is needed for continuous analysis
|
||||
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
|
||||
@@ -150,7 +167,7 @@ of the first command's active span.
|
||||
The `ProfilingManager` self-profiles its own `span.__enter__` and `span.__exit__` calls.
|
||||
Exposes:
|
||||
|
||||
- `overhead_per_span_ns` — average cost of one span boundary in nanoseconds
|
||||
- `overhead_per_span_us` — average cost of one span boundary in microseconds
|
||||
- `total_overhead_ms` — estimated total overhead across all active spans
|
||||
|
||||
Visible in the UI to verify the profiler does not bias measurements significantly.
|
||||
@@ -186,19 +203,20 @@ CumulativeSpan
|
||||
|
||||
---
|
||||
|
||||
## Existing Code Hooks
|
||||
## Code Hooks
|
||||
|
||||
### `src/myfasthtml/core/utils.py` — route handler (Level A)
|
||||
### `src/myfasthtml/core/utils.py` — route handler (Level A) ✅
|
||||
|
||||
```python
|
||||
@utils_rt(Routes.Commands)
|
||||
async def post(session, c_id: str, client_response: dict = None):
|
||||
with profiler.span("command", args={"c_id": c_id}):
|
||||
command = CommandsManager.get_command(c_id)
|
||||
return await command.execute(client_response)
|
||||
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)
|
||||
### `src/myfasthtml/core/commands.py` — execution phases (Level B) ⏳ Deferred
|
||||
|
||||
Planned breakdown inside `Command.execute()`:
|
||||
|
||||
```python
|
||||
def execute(self, client_response=None):
|
||||
@@ -212,54 +230,120 @@ def execute(self, client_response=None):
|
||||
...
|
||||
```
|
||||
|
||||
Deferred: will be added once the UI control is functional to immediately observe the breakdown.
|
||||
|
||||
---
|
||||
|
||||
## UI Control Design
|
||||
|
||||
### Control name: `Profiler` (SingleInstance)
|
||||
|
||||
Single entry point. Replaces the earlier `ProfilerList` name.
|
||||
|
||||
**Files:**
|
||||
- `src/myfasthtml/controls/Profiler.py`
|
||||
- `src/myfasthtml/assets/core/profiler.css`
|
||||
|
||||
### Layout
|
||||
|
||||
Split view using `Panel`:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [●] [🗑] Overhead/span: 1.2µs Traces: 8/500│ ← toolbar (icon-only)
|
||||
├──────────────────────┬──────────────────────────────┤
|
||||
│ Command Duration Time│ NavigateCell — 173.4ms [≡][◕]│
|
||||
│ ──────────────────────│ ─────────────────────────────│
|
||||
│ NavigateCell 173ms … │ [Metadata card] │
|
||||
│ NavigateCell 168ms … │ [kwargs card] │
|
||||
│ SelectRow 42ms … │ [Span breakdown / Pie chart] │
|
||||
│ … │ │
|
||||
└──────────────────────┴──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Toolbar
|
||||
|
||||
Icon-only buttons, no `Menu` control (Menu does not support toggle state).
|
||||
Direct `mk.icon()` calls:
|
||||
|
||||
- **Enable/disable**: icon changes between "recording" and "stopped" states based on `profiler.enabled`
|
||||
- **Clear**: delete icon, always red
|
||||
- **Refresh**: manual refresh of the trace list (no auto-refresh yet — added in Step 2.1)
|
||||
|
||||
Overhead metrics displayed as plain text on the right side of the toolbar.
|
||||
|
||||
### Trace list (left panel)
|
||||
|
||||
Three columns: command name / duration (color-coded) / timestamp.
|
||||
Click on a row → update right panel via HTMX.
|
||||
|
||||
**Duration color thresholds:**
|
||||
- Green (`mf-profiler-fast`): < 20 ms
|
||||
- Orange (`mf-profiler-medium`): 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
|
||||
### Phase 1 — Core ✅ Complete
|
||||
|
||||
**File**: `src/myfasthtml/core/profiler.py`
|
||||
1. `ProfilingSpan`, `CumulativeSpan`, `ProfilingTrace` dataclasses
|
||||
2. `ProfilingManager` with all probe mechanisms
|
||||
3. `profiler` singleton
|
||||
4. Hook into `utils.py` (Level A) ✅
|
||||
5. Hook into `commands.py` (Level B) — deferred
|
||||
|
||||
1. `ProfilingSpan` dataclass
|
||||
2. `CumulativeSpan` dataclass
|
||||
3. `ProfilingTrace` dataclass
|
||||
4. `ProfilingManager` class with all probe mechanisms
|
||||
5. `profiler` singleton
|
||||
6. Hook into `utils.py` (Level A)
|
||||
7. Hook into `commands.py` (Level B)
|
||||
|
||||
**Tests**: `tests/core/test_profiler.py`
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `test_i_can_create_a_span` | Basic span creation and timing |
|
||||
| `test_i_can_nest_spans` | Child spans are correctly parented |
|
||||
| `test_i_can_use_span_as_decorator` | Decorator captures args automatically |
|
||||
| `test_i_can_use_cumulative_span` | Aggregates count/total/min/max/avg |
|
||||
| `test_i_can_attach_data_to_span` | `span.set()` and `current_span().set()` |
|
||||
| `test_i_can_clear_traces` | Buffer is emptied after `clear()` |
|
||||
| `test_i_can_enable_disable_profiler` | Probes are no-ops when disabled |
|
||||
| `test_i_can_measure_overhead` | Overhead metrics are exposed |
|
||||
| `test_i_can_use_trace_all_on_class` | All methods of a class are wrapped |
|
||||
| `test_i_can_use_trace_calls_on_function` | Sub-calls are traced via setprofile |
|
||||
**Tests**: `tests/core/test_profiler.py` — 7 classes, full coverage ✅
|
||||
|
||||
### Phase 2 — Controls
|
||||
|
||||
**`src/myfasthtml/controls/ProfilerList.py`** (SingleInstance)
|
||||
- Table of all traces: command name / total duration / timestamp
|
||||
- Right panel: trace detail (kwargs, span breakdown)
|
||||
- Buttons: enable/disable, clear
|
||||
- Click on a trace → opens ProfilerDetail
|
||||
#### Step 2.1 — Global layout (current) 🔄
|
||||
|
||||
**`src/myfasthtml/controls/ProfilerDetail.py`** (MultipleInstance)
|
||||
- Hierarchical span tree for a single trace
|
||||
- Two display modes: list and pie chart
|
||||
- Click on a span → zooms into its children (if any)
|
||||
- Displays cumulative spans with count/min/max/avg
|
||||
- Shows overhead metrics
|
||||
`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/controls/ProfilerPieChart.py`** (future)
|
||||
- Pie chart visualization of span distribution at a given zoom level
|
||||
`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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
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")
|
||||
|
||||
177
src/myfasthtml/assets/core/profiler.css
Normal file
177
src/myfasthtml/assets/core/profiler.css
Normal file
@@ -0,0 +1,177 @@
|
||||
/* ================================================================== */
|
||||
/* Profiler Control */
|
||||
/* ================================================================== */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Root wrapper — fills parent, stacks toolbar above panel
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Toolbar
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Danger variant for clear button */
|
||||
.mf-profiler-btn-danger {
|
||||
color: var(--color-error) !important;
|
||||
}
|
||||
|
||||
.mf-profiler-btn-danger:hover {
|
||||
background: color-mix(in oklab, var(--color-error) 15%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Overhead metrics — right-aligned text */
|
||||
.mf-profiler-overhead {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Trace list — left panel content
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 96px;
|
||||
padding: 4px 10px;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: var(--text-xs);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-col-header {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.mf-profiler-col-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mf-profiler-list-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Trace row
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 96px;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-border) 60%, transparent);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.mf-profiler-row:hover {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-profiler-row-selected {
|
||||
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.mf-profiler-row-selected:hover {
|
||||
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
/* Command name + description cell */
|
||||
.mf-profiler-cmd-cell {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mf-profiler-cmd {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mf-profiler-cmd-description {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
font-style: italic;
|
||||
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Duration cell — monospace, color-coded */
|
||||
.mf-profiler-duration {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mf-profiler-fast {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.mf-profiler-medium {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.mf-profiler-slow {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Timestamp cell */
|
||||
.mf-profiler-ts {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
text-align: right;
|
||||
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Empty state
|
||||
------------------------------------------------------------------ */
|
||||
.mf-profiler-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
|
||||
}
|
||||
@@ -272,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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
202
src/myfasthtml/controls/Profiler.py
Normal file
202
src/myfasthtml/controls/Profiler.py
Normal file
@@ -0,0 +1,202 @@
|
||||
import logging
|
||||
|
||||
from fasthtml.components import Div, Span
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.IconsHelper import IconsHelper
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import PROFILER_MAX_TRACES, MediaActions
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.core.profiler import profiler
|
||||
from myfasthtml.icons.fluent import arrow_clockwise20_regular
|
||||
|
||||
logger = logging.getLogger("Profiler")
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
|
||||
def toggle_enable(self):
|
||||
return Command(
|
||||
"ProfilerToggleEnable",
|
||||
"Enable / Disable profiler",
|
||||
self._owner,
|
||||
self._owner.handle_toggle_enable,
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def clear_traces(self):
|
||||
return Command(
|
||||
"ProfilerClearTraces",
|
||||
"Clear all recorded traces",
|
||||
self._owner,
|
||||
self._owner.handle_clear_traces,
|
||||
icon=IconsHelper.get(MediaActions.Cancel),
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def refresh(self):
|
||||
return Command(
|
||||
"ProfilerRefresh",
|
||||
"Refresh traces",
|
||||
self._owner,
|
||||
self._owner.handle_refresh,
|
||||
icon=arrow_clockwise20_regular,
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
def select_trace(self, trace_id: str):
|
||||
return Command(
|
||||
"ProfilerSelectTrace",
|
||||
"Display trace details",
|
||||
self._owner,
|
||||
self._owner.handle_select_trace,
|
||||
kwargs={"trace_id": trace_id},
|
||||
).htmx(target=f"#{self._id}")
|
||||
|
||||
|
||||
class Profiler(SingleInstance):
|
||||
"""In-application profiler UI.
|
||||
|
||||
Displays all recorded traces in a scrollable list (left) and trace
|
||||
details in a resizable panel (right). The toolbar provides enable /
|
||||
disable toggle and clear actions via icon-only buttons.
|
||||
|
||||
Attributes:
|
||||
commands: Commands exposed by this control.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._panel = Panel(self, conf=PanelConf(show_right_title=False, show_display_right=False))
|
||||
self._selected_id: str | None = None
|
||||
self.commands = Commands(self)
|
||||
logger.debug(f"Profiler created with id={self._id}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Command handlers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle_toggle_enable(self):
|
||||
"""Toggle profiler.enabled and re-render."""
|
||||
profiler.enabled = not profiler.enabled
|
||||
logger.debug(f"Profiler enabled set to {profiler.enabled}")
|
||||
return self
|
||||
|
||||
def handle_clear_traces(self):
|
||||
"""Clear the trace buffer and re-render."""
|
||||
profiler.clear()
|
||||
logger.debug("Profiler traces cleared from UI")
|
||||
return self
|
||||
|
||||
def handle_select_trace(self, trace_id: str):
|
||||
"""Select a trace row and re-render to show it highlighted."""
|
||||
self._selected_id = trace_id
|
||||
return self
|
||||
|
||||
def handle_refresh(self):
|
||||
"""Select a trace row and re-render to show it highlighted."""
|
||||
return self
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private rendering helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _duration_cls(self, duration_ms: float) -> str:
|
||||
"""Return the CSS modifier class for a given duration."""
|
||||
if duration_ms < 20:
|
||||
return "mf-profiler-fast"
|
||||
if duration_ms < 100:
|
||||
return "mf-profiler-medium"
|
||||
return "mf-profiler-slow"
|
||||
|
||||
def _mk_toolbar(self):
|
||||
"""Build the icon toolbar with enable/disable, clear and overhead metrics."""
|
||||
enable_icon = (
|
||||
IconsHelper.get(MediaActions.Pause)
|
||||
if profiler.enabled
|
||||
else IconsHelper.get(MediaActions.Play)
|
||||
)
|
||||
enable_tooltip = "Disable profiler" if profiler.enabled else "Enable profiler"
|
||||
|
||||
overhead = (
|
||||
f"Overhead/span: {profiler.overhead_per_span_us:.1f} µs "
|
||||
f"Total: {profiler.total_overhead_ms:.3f} ms "
|
||||
f"Traces: {len(profiler.traces)} / {PROFILER_MAX_TRACES}"
|
||||
)
|
||||
|
||||
return Div(
|
||||
mk.icon(
|
||||
enable_icon,
|
||||
command=self.commands.toggle_enable(),
|
||||
tooltip=enable_tooltip,
|
||||
),
|
||||
mk.icon(
|
||||
command=self.commands.clear_traces(),
|
||||
tooltip="Clear traces",
|
||||
cls="mf-profiler-btn-danger",
|
||||
),
|
||||
mk.icon(
|
||||
command=self.commands.refresh(),
|
||||
),
|
||||
Span(overhead, cls="mf-profiler-overhead"),
|
||||
cls="mf-profiler-toolbar",
|
||||
id=f"tb_{self._id}",
|
||||
)
|
||||
|
||||
def _mk_trace_list(self):
|
||||
"""Build the trace list with one clickable row per recorded trace."""
|
||||
traces = profiler.traces
|
||||
if not traces:
|
||||
return Div("No traces recorded.", cls="mf-profiler-empty")
|
||||
|
||||
rows = []
|
||||
for trace in traces:
|
||||
ts = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
|
||||
duration_cls = self._duration_cls(trace.total_duration_ms)
|
||||
row_cls = "mf-profiler-row mf-profiler-row-selected" if trace.trace_id == self._selected_id else "mf-profiler-row"
|
||||
|
||||
row = mk.mk(
|
||||
Div(
|
||||
Div(
|
||||
Span(trace.command_name, cls="mf-profiler-cmd"),
|
||||
Span(trace.command_description, cls="mf-profiler-cmd-description"),
|
||||
cls="mf-profiler-cmd-cell",
|
||||
),
|
||||
Span(f"{trace.total_duration_ms:.1f} ms", cls=f"mf-profiler-duration {duration_cls}"),
|
||||
Span(ts, cls="mf-profiler-ts"),
|
||||
cls=row_cls,
|
||||
),
|
||||
command=self.commands.select_trace(trace.trace_id),
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
return Div(
|
||||
Div(
|
||||
Span("Command", cls="mf-profiler-col-header"),
|
||||
Span("Duration", cls="mf-profiler-col-header mf-profiler-col-right"),
|
||||
Span("Time", cls="mf-profiler-col-header mf-profiler-col-right"),
|
||||
cls="mf-profiler-list-header",
|
||||
),
|
||||
Div(*rows, cls="mf-profiler-list-body"),
|
||||
cls="mf-profiler-list",
|
||||
)
|
||||
|
||||
def _mk_detail_placeholder(self):
|
||||
"""Placeholder shown in the right panel before a trace is selected."""
|
||||
return Div("Select a trace to view details.", cls="mf-profiler-empty")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self):
|
||||
self._panel.set_main(self._mk_trace_list())
|
||||
self._panel.set_right(self._mk_detail_placeholder())
|
||||
return Div(
|
||||
self._mk_toolbar(),
|
||||
self._panel,
|
||||
id=self._id,
|
||||
cls="mf-profiler",
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -33,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("_")]
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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")
|
||||
|
||||
@@ -114,6 +115,7 @@ class ProfilingTrace:
|
||||
|
||||
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.
|
||||
@@ -122,11 +124,13 @@ class ProfilingTrace:
|
||||
"""
|
||||
|
||||
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()))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -236,10 +240,15 @@ class _CommandSpan:
|
||||
Used exclusively by the route handler to wrap the full command execution.
|
||||
"""
|
||||
|
||||
def __init__(self, manager: 'ProfilingManager', command_name: str,
|
||||
command_id: str, kwargs: dict):
|
||||
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
|
||||
@@ -249,6 +258,7 @@ class _CommandSpan:
|
||||
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(),
|
||||
@@ -403,7 +413,10 @@ class ProfilingManager:
|
||||
"""
|
||||
return _CumulativeActiveSpan(self, name)
|
||||
|
||||
def command_span(self, command_name: str, command_id: str,
|
||||
def command_span(self,
|
||||
command_name: str,
|
||||
command_description: str,
|
||||
command_id: str,
|
||||
kwargs: dict) -> '_CommandSpan | _NullSpan':
|
||||
"""Context manager for the route handler.
|
||||
|
||||
@@ -412,6 +425,7 @@ class ProfilingManager:
|
||||
|
||||
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.
|
||||
|
||||
@@ -420,7 +434,7 @@ class ProfilingManager:
|
||||
"""
|
||||
if not self.enabled:
|
||||
return _NullSpan()
|
||||
return _CommandSpan(self, command_name, command_id, kwargs)
|
||||
return _CommandSpan(self, command_name, command_description, command_id, kwargs)
|
||||
|
||||
def trace_all(self, cls=None, *, exclude: list = None):
|
||||
"""Class decorator — statically wraps all non-dunder methods with spans.
|
||||
|
||||
@@ -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,6 +380,7 @@ 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}.")
|
||||
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.")
|
||||
|
||||
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")))
|
||||
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}'"
|
||||
@@ -348,7 +348,7 @@ 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.command_span("NavigateCell", "", "abc-123", {"row": "5"}):
|
||||
with p.span("callback"):
|
||||
time.sleep(0.01)
|
||||
|
||||
@@ -366,7 +366,7 @@ class TestCommandSpan:
|
||||
"""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", {}):
|
||||
with p.command_span("cmd", "", "id", {}):
|
||||
pass
|
||||
|
||||
assert len(p.traces) == 0
|
||||
@@ -375,7 +375,7 @@ class TestCommandSpan:
|
||||
"""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), {}):
|
||||
with p.command_span(f"cmd_{i}", "", str(i), {}):
|
||||
pass
|
||||
|
||||
assert len(p.traces) == 10, "Buffer should cap at max_traces=10"
|
||||
@@ -384,7 +384,7 @@ class TestCommandSpan:
|
||||
"""Test that a recorded trace contains a valid datetime timestamp."""
|
||||
p = fresh_profiler
|
||||
before = datetime.now()
|
||||
with p.command_span("MyCmd", "id-001", {}):
|
||||
with p.command_span("MyCmd", "", "id-001", {}):
|
||||
pass
|
||||
after = datetime.now()
|
||||
|
||||
@@ -395,13 +395,31 @@ class TestCommandSpan:
|
||||
"""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):
|
||||
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
|
||||
@@ -630,9 +648,9 @@ class TestProfilingManager:
|
||||
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", {}):
|
||||
with p.command_span("cmd", "", "uuid-1", {}):
|
||||
pass
|
||||
with p.command_span("cmd", "uuid-2", {}):
|
||||
with p.command_span("cmd", "", "uuid-2", {}):
|
||||
pass
|
||||
|
||||
assert len(p.traces) == 2
|
||||
|
||||
Reference in New Issue
Block a user