diff --git a/.github/workflows/soak.yml b/.github/workflows/soak.yml index 6dc46300..636fdf8f 100644 --- a/.github/workflows/soak.yml +++ b/.github/workflows/soak.yml @@ -53,7 +53,7 @@ jobs: env: SKIP_FRONTEND_CLEAN: "1" run: | - if [ ! -f python/probing/bundled_web/public/index.html ]; then + if [ ! -f probing/server/web-assets/public/index.html ]; then make frontend fi make wheel-ci @@ -108,7 +108,7 @@ jobs: env: SKIP_FRONTEND_CLEAN: "1" run: | - if [ ! -f python/probing/bundled_web/public/index.html ]; then + if [ ! -f probing/server/web-assets/public/index.html ]; then make frontend fi make wheel-ci diff --git a/.gitignore b/.gitignore index c7fcd3a7..2c86c116 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ docs/site/ web/target/ probing/server/web-assets/ /web/dist +# Legacy Python-side Web copy (assets embed into probing._core via server/web-assets) +python/probing/bundled_web/ frontend/ diff --git a/AGENTS.md b/AGENTS.md index 41f6735f..1c52f7a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,8 +180,8 @@ Keep the propagation chain clean — don't reintroduce scattered `map_err`/`insp ## Skills -All diagnostic skills live under **`skills/`** (authoring SSOT; wheel copy in -`python/probing/bundled_skills/`). Each subdirectory contains: +All diagnostic skills live under **`python/probing/bundled_skills/`** (content SSOT; +repo-root `skills/` is a symlink alias). Each subdirectory contains: - **`SKILL.md`** — when to use the skill and how to interpret results (read this for routing) - **`steps.yaml`** — executable probe steps (used by `probing skill run` and the Web Investigate agent) diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 00000000..9a750548 --- /dev/null +++ b/docs/archive/README.md @@ -0,0 +1,3 @@ +# Archived reviews + +One-off design/implementation review notes (2025-07). Not part of the living design docs; see `docs/src/design/modularity.md` for current boundaries. diff --git a/REVIEW.md b/docs/archive/REVIEW.md similarity index 100% rename from REVIEW.md rename to docs/archive/REVIEW.md diff --git a/REVIEW_COMPARISON.md b/docs/archive/REVIEW_COMPARISON.md similarity index 100% rename from REVIEW_COMPARISON.md rename to docs/archive/REVIEW_COMPARISON.md diff --git a/REVIEW_METHODOLOGY.md b/docs/archive/REVIEW_METHODOLOGY.md similarity index 100% rename from REVIEW_METHODOLOGY.md rename to docs/archive/REVIEW_METHODOLOGY.md diff --git a/docs/src/contributing.md b/docs/src/contributing.md index eb89026a..e7a75fea 100644 --- a/docs/src/contributing.md +++ b/docs/src/contributing.md @@ -42,7 +42,7 @@ Preview docs while editing: `make docs-install && make docs-serve` → http://12 | **Rust** | SQL engine, server, collectors, CLI | `probing/` (Rust workspace) | [Modularity](design/modularity.md) | Issues in `probing/core`, `probing/server`, extensions | | **Web UI** | Investigate agent, dashboards | `web/` | [web/DESIGN.md](https://github.com/DeepLink-org/probing/blob/main/web/DESIGN.md) | Agent UX, page polish (needs `dx` for full wheel build) | -**Skills vs Python package:** edit skill **data** in repo-root `skills/` (symlink to `python/probing/bundled_skills/`); edit skill **loader / install code** in `python/probing/skills/`. +**Skills vs Python package:** skill **data** SSOT is `python/probing/bundled_skills/` (repo-root `skills/` is a symlink to it); edit skill **loader / install code** in `python/probing/skills/`. **Two folders named `probing/`:** `probing/` at the repo root is **Rust**; `python/probing/` is the **Python package**. `src/lib.rs` at the root is the PyO3 entry for `probing._core`. @@ -178,9 +178,9 @@ See [examples/README.md](https://github.com/DeepLink-org/probing/blob/main/examp ## Skills & agents {#skills-agents} -- **Authoring**: repo root `skills/` (`SKILL.md`, `steps.yaml`, `catalog.yaml`) +- **Authoring**: `python/probing/bundled_skills/` (repo-root `skills/` symlink; `SKILL.md`, `steps.yaml`, `catalog.yaml`) - **Install to IDE agents**: `./skills/install.sh` or `probing skill install` -- **Bundled in wheel**: `make wheel` copies skills into `python/probing/bundled_skills/`; `make frontend` builds ignored UI artifacts under `probing/server/web-assets/`, and the server build script embeds them into `probing._core` at compile time (plain Rust builds use a tracked fallback page) +- **Bundled in wheel**: maturin packages `python/probing/bundled_skills/` directly (no separate copy step); `make frontend` builds ignored UI artifacts under `probing/server/web-assets/`, and the server build script embeds them into `probing._core` at compile time (plain Rust builds use a tracked fallback page) - **Docs**: `skills/README.md`, [Extensibility — Diagnostic skill](design/extensibility.md#path-2-diagnostic-skill) ## Development workflow @@ -246,11 +246,11 @@ Advanced: `cd docs && make deploy` for GitHub Pages. ``` probing/ # repo root -├── skills/ # skill DATA (authoring) — see skills/README.md +├── skills/ # symlink → python/probing/bundled_skills/ (authoring alias) ├── python/ │ ├── probing/ # Python PACKAGE (not Rust) │ │ ├── skills/ # skill loader/install CODE — see python/probing/skills/README.md -│ │ └── bundled_skills/ # skill DATA bundled in wheel (author in repo-root skills/) +│ │ └── bundled_skills/ # skill DATA SSOT (packaged in wheel) │ ├── probing_hook.py # .pth → site hook │ └── probing.pth ├── src/lib.rs # PyO3 entry → probing._core (maturin) diff --git a/docs/src/contributing.zh.md b/docs/src/contributing.zh.md index a8899385..ecd59309 100644 --- a/docs/src/contributing.zh.md +++ b/docs/src/contributing.zh.md @@ -42,7 +42,7 @@ make test-python-regression # Python 冒烟;完整套件:make test | **Rust** | 引擎、服务、采集、CLI | `probing/`(Rust workspace) | [模块化](design/modularity.zh.md) | `probing/core`、`probing/server` 相关 issue | | **Web UI** | Investigate、各页面 | `web/` | [web/DESIGN.md](https://github.com/DeepLink-org/probing/blob/main/web/DESIGN.md) | Agent 体验(完整 wheel 构建需 `dx`) | -**Skill 数据 vs Python 包:** skill **内容**改仓库根 `skills/`(wheel 复制到 `python/probing/bundled_skills/`);**加载/安装代码**改 `python/probing/skills/`。**不要**手改 `python/probing/bundled_skills/`(由 `make wheel` 自动生成)。 +**Skill 数据 vs Python 包:** skill **内容** SSOT 是 `python/probing/bundled_skills/`(仓库根 `skills/` 为其符号链接别名);**加载/安装代码**改 `python/probing/skills/`。 **两个 `probing/` 目录:** 仓库根 `probing/` 是 **Rust**;`python/probing/` 是 **Python 包**。根目录 `src/lib.rs` 是 PyO3 入口,构建为 `probing._core`。 @@ -174,9 +174,9 @@ PROBING=1 python examples/getting-started/tracing.py ## Skills 与 Agent {#skills-agents} -- **编写**:仓库根 `skills/`(`SKILL.md`、`steps.yaml`、`catalog.yaml`) +- **编写**:`python/probing/bundled_skills/`(仓库根 `skills/` 为符号链接;`SKILL.md`、`steps.yaml`、`catalog.yaml`) - **安装到 IDE**:`./skills/install.sh` 或 `probing skill install` -- **打进 wheel**:`make wheel` 复制 Skill 到 `python/probing/bundled_skills/`;`make frontend` 将被忽略的 UI 产物构建到 `probing/server/web-assets/`,server build script 在编译时把它嵌入 `probing._core`(普通 Rust 构建使用受版本控制的 fallback 页面) +- **打进 wheel**:maturin 直接打包 `python/probing/bundled_skills/`(无额外复制步骤);`make frontend` 将被忽略的 UI 产物构建到 `probing/server/web-assets/`,server build script 在编译时把它嵌入 `probing._core`(普通 Rust 构建使用受版本控制的 fallback 页面) - **说明**:`skills/README.md`、[扩展机制 — 诊断 skill](design/extensibility.zh.md#path-2-diagnostic-skill) ## 开发流程 @@ -241,11 +241,11 @@ make docs # 静态构建 → docs/site/ ``` probing/ # 仓库根 -├── skills/ # skill 数据(编写)— 见 skills/README.md +├── skills/ # 符号链接 → python/probing/bundled_skills/(编写别名) ├── python/ │ ├── probing/ # Python 包(不是 Rust) │ │ ├── skills/ # skill 加载/安装代码 — 见 python/probing/skills/README.md -│ │ └── bundled_skills/ # wheel 打包的 skill 数据(编写在 repo-root skills/) +│ │ └── bundled_skills/ # skill 数据 SSOT(直接打进 wheel) │ ├── probing_hook.py │ └── probing.pth ├── src/lib.rs # PyO3 → probing._core diff --git a/docs/src/design/architecture.zh.md b/docs/src/design/architecture.zh.md index 52010860..56306ae1 100644 --- a/docs/src/design/architecture.zh.md +++ b/docs/src/design/architecture.zh.md @@ -1,176 +1,8 @@ # 系统架构 !!! warning "旧版文档" - 本页的两层概览已被 **[模块化与边界](modularity.zh.md)**(四层模型、crate 地图、依赖规则与归属)**取代**。仅保留历史示意图。**新贡献者请从 [模块化](modularity.zh.md)** 入手,再读 [数据层](data-layer.zh.md) 与 [分布式概览](distributed.zh.md)。 + 本页的两层概览已被 **[模块化与边界](modularity.zh.md)**(四层模型、crate 地图、依赖规则与归属)**取代**。 -Probing 采用简洁的两层架构设计,以最小化复杂性和部署难度。 +**新贡献者:** 从 [模块化与边界](modularity.zh.md) 入手,再读 [数据层](data-layer.zh.md) 与 [分布式概览](distributed.zh.md)。 -## 高层架构 - -```mermaid -graph TB - subgraph "用户界面" - CLI[CLI 客户端] - HTTP[HTTP API] - WEB[Web UI] - end - - subgraph "目标进程" - PROBE[探针] - subgraph "探针组件" - ENGINE[引擎] - SERVER[服务器] - EXT[扩展] - end - end - - CLI --> |Unix Socket / TCP| PROBE - HTTP --> |HTTP/REST| PROBE - WEB --> |WebSocket| PROBE - - PROBE --> ENGINE - PROBE --> SERVER - PROBE --> EXT -``` - -## 组件 - -### 1. 探针 (Probe) - -注入到目标进程中,获得对所有资源的完全访问权限: - -- Python 解释器访问 -- 文件系统访问 -- 内存检查 -- 网络能力 - -探针运行一个嵌入式 HTTP 服务器,监听在: - -- **Unix 域套接字** - 用于本地连接(默认) -- **TCP 端口** - 用于远程连接 - -### 2. CLI - -用于控制探针的命令行界面: - -- 进程发现和列表 -- 探针注入和管理 -- 查询执行 -- 代码执行 - -通过 HTTP 协议在 Unix 域套接字(本地)或 TCP(远程)上通信。 - -### 3. HTTP API - -用于编程访问的 RESTful API: - -- 所有 CLI 命令都可作为端点使用 -- WebSocket 支持实时数据 -- 与监控工具集成 - -## 探针内部架构 - -```mermaid -graph LR - subgraph "探针" - SERVER[HTTP 服务器] - ENGINE[查询引擎] - CONFIG[配置管理] - - subgraph "扩展" - PYTHON[Python 扩展] - TORCH[PyTorch 扩展] - CUSTOM[自定义表] - end - end - - SERVER --> ENGINE - SERVER --> CONFIG - ENGINE --> PYTHON - ENGINE --> TORCH - ENGINE --> CUSTOM -``` - -### 引擎 - -核心数据存储和处理: - -- **DataFusion** - SQL 查询引擎 -- **Arrow** - 列式数据格式 -- 时间序列数据存储 -- 配置管理 - -### 服务器 - -HTTP 服务器处理: - -- 请求路由 -- 认证(可选) -- WebSocket 连接 -- 响应格式化 - -### 扩展 - -可插拔的数据提供者: - -- **Python 扩展** - 堆栈跟踪、变量 -- **PyTorch 扩展** - Torch 跟踪、内存 -- **自定义表** - 用户定义的数据源 - -## 数据流 - -```mermaid -sequenceDiagram - participant CLI - participant 探针 - participant 引擎 - participant 扩展 - - CLI->>探针: query "SELECT * FROM python.torch_trace" - 探针->>引擎: 解析 & 计划查询 - 引擎->>扩展: 请求数据 - 扩展-->>引擎: Arrow RecordBatch - 引擎-->>探针: 查询结果 - 探针-->>CLI: JSON 响应 -``` - -## 通信协议 - -### 本地连接 - -``` -probing -t query "..." - | - v - Unix Socket: /tmp/probing-.sock - | - v - HTTP 请求: POST /query -``` - -### 远程连接 - -``` -probing -t host:port query "..." - | - v - TCP 连接: host:port - | - v - HTTP 请求: POST /query -``` - -## 安全考虑 - -- **本地模式**: Unix 套接字权限(仅进程所有者) -- **远程模式**: 可选认证 -- **网络**: 支持 TLS 加密 - -## 性能特征 - -| 方面 | 目标 | -|------|------| -| 开销 | 典型工作负载 < 5% | -| 内存 | 额外 < 50MB | -| 延迟 | 查询 < 10ms | -| 吞吐量 | 1000+ 查询/秒 | +为避免与当前 crate 地图漂移,旧版两层 Mermaid 示意图已移除。 diff --git a/docs/src/design/modularity.md b/docs/src/design/modularity.md index 410b32b7..9ce2bc72 100644 --- a/docs/src/design/modularity.md +++ b/docs/src/design/modularity.md @@ -128,7 +128,7 @@ component state, logs, and readiness responses. | Unit | Path | Responsibility | |------|------|----------------| | **web/** | Dioxus WASM | Pages, visualization, Investigate agent | -| **skills/** | YAML + SKILL.md | Skill content SSOT | +| **skills/** | symlink → `python/probing/bundled_skills/` | Authoring alias for skill content SSOT | | **probing-skills** | `probing/crates/skills/` | Shared loader, interpreter, runner (CLI / Web / MCP) | | **python/probing/extensions/** | entry points | Skill + magic + vendor package discovery | | **python/probing/** | Python package | Hooks, `query()`, agent install helpers | @@ -192,7 +192,7 @@ class CommCollective: ... ### 3.4 Skill contract — diagnostic workflows -**Where:** `skills//SKILL.md` + `steps.yaml`, catalog `skills/catalog.yaml` +**Where:** `python/probing/bundled_skills//SKILL.md` + `steps.yaml` (alias: `skills/`), catalog `catalog.yaml` | Field | Purpose | |-------|---------| @@ -205,7 +205,7 @@ class CommCollective: ... - Skills **only** talk to the engine via SQL (`probing query`) or documented HTTP APIs. - No Rust/Python code in skills — YAML + markdown only. -- **Content SSOT:** `skills/` (wheel: `python/probing/bundled_skills/`). +- **Content SSOT:** `python/probing/bundled_skills/` (repo-root `skills/` is a symlink alias). - **Discovery:** `python/probing/extensions/` entry points + `GET /apis/pythonext/skills/*`. - **Execution SSOT:** `probing-skills` — CLI (`probing/cli/skill/`), Web WASM (`web/src/agent/runner.rs`), MCP (`run_skill` / `plan_skill` in server). @@ -259,7 +259,7 @@ Forbidden (fix if found): L1 → L2/L3/L4 L2 → L2 (collector cross-deps) L2 → L3 (extensions must not import server) - L2 → probing-cli — **packaging only** (see §4.1; not a runtime collector→control violation) + L2 → probing-cli — collectors must not depend on CLI (wheel `cli_main` is wired in root `src/lib.rs`; see §4.1) skills → Rust internals web → probing-core / pyo3 ``` @@ -276,7 +276,7 @@ Forbidden (fix if found): | **cli** | ✓ | opt | | | | — | | **web** | ✓ | | | | | | -### 4.1 PyPI packaging coupling (`probing-python` → `probing-cli`) +### 4.1 PyPI packaging coupling (cdylib → `probing-cli`) Maturin builds **one native artifact** (`probing._core` cdylib from root `Cargo.toml`). The `probing` console script is **not** a separate Rust binary on PyPI: @@ -293,15 +293,16 @@ control plane at runtime for data paths. **Contract (keep the edge thin):** -- `probing-python` may depend on `probing-cli` **only** to re-export `cli_main` in - `features/python_api.rs` for the PyO3 entrypoint. +- Root `src/lib.rs` (the maturin cdylib composition root) may call `probing_cli::pyo3::cli_main` + and re-export it as `_core.cli_main`. `probing-python` itself does **not** depend on + `probing-cli`. - Do **not** import other `probing-cli` modules (inject, skill runner internals, ctrl) from collectors or server. - Standalone Rust binary (`probing/cli/src/main.rs`) remains optional for non-PyPI installs; PyPI users always go through the Python script entry. If CLI logic grows, split **`probing-cli-lib`** (shared `cli_main` + HTTP client) from CLI-only -commands, rather than letting `probing-python` spread imports across the cli crate. +commands, rather than letting collectors or the cdylib spread imports across the cli crate. --- @@ -387,11 +388,11 @@ Track and fix incrementally: | Issue | Current | Target | |-------|---------|--------| -| Python ext → CLI | `probing-python` → `probing-cli` | **Accepted** for maturin wheel (`cli_main` only); keep import surface minimal | +| Python ext → CLI | ~~`probing-python` → `probing-cli`~~ | **Done** — no crate dep; wheel `cli_main` wired only in root `src/lib.rs` (§4.1) | | Python ext → CC | ~~`probing-python` → `probing-cc`~~ | **Done** — `send_sigusr2_to_thread_id` moved to `probing-core::signal` | | Core → NCCL/HCCL | ~~`probing-core` → `probing-nccl-profiler` / `probing-hccl-shim` (`builtin-schema-docs` feature) for `semantic_catalog`~~ | **Done** — `register_docs()` called from `server/engine.rs` composition root; `probing-core` default features empty | | Core → skills YAML | ~~`semantic_catalog.rs` `include_str!(skills/semantic/tables.yaml)`~~ | **Done** — overlay at `probing/core/resources/tables.yaml`; descriptions SSOT in `docs` registry | -| Server → python `features/*` | ~~`server/profiling.rs`~~ removed | Flamegraphs via `torchextension` / `pprofextension` `ProbeExtensionCall` | +| Server → python `features/*` | ~~`server/profiling.rs` / training flamegraph handlers~~ | **Done** — flamegraphs via `torchextension` / `pprofextension` `ProbeExtensionCall` only | | Server → python REPL internals | ~~`PythonRepl` in server~~ | `/ws` uses `ReplSession` facade only | | Composition sprawl | All wiring in `server/engine.rs` | Optional: manifest TOML listing enabled extensions | | Skills triple loader | ~~Rust + Python + Web compile-time embed~~ | **Done** — `probing-skills` is loader/interpret/runner SSOT; Python keeps discovery entry-points + PyO3 serialize bridge; Web deserializes API into shared types | diff --git a/docs/src/design/modularity.zh.md b/docs/src/design/modularity.zh.md index feb2ad7d..2e0e96b1 100644 --- a/docs/src/design/modularity.zh.md +++ b/docs/src/design/modularity.zh.md @@ -122,7 +122,7 @@ HTTP 契约:`probing/server/API.md` + `tests/regression/spec/api_spec.json`。 | 单元 | 路径 | 职责 | |------|------|------| | **web/** | Dioxus | 页面、可视化、Investigate Agent | -| **skills/** | YAML + SKILL.md | 诊断 skill 内容 SSOT | +| **skills/** | 符号链接 → `python/probing/bundled_skills/` | skill 内容 SSOT 的编写别名 | | **probing-skills** | `probing/crates/skills/` | 共享 loader、解释器、runner(CLI / Web / MCP) | | **python/probing/extensions/** | entry points | Skills + magics + 厂商包发现(代码 SSOT) | | **python/probing/** | Python 包 | hooks、`query()`、agent 安装辅助 | @@ -163,7 +163,7 @@ HTTP 契约:`probing/server/API.md` + `tests/regression/spec/api_spec.json`。 ### 3.4 Skill 契约 — 诊断工作流 -**SSOT:** `skills//` + `skills/catalog.yaml` +**SSOT:** `python/probing/bundled_skills//` + `catalog.yaml`(仓库根 `skills/` 为符号链接别名) | 字段 | 作用 | |------|------| @@ -174,7 +174,7 @@ HTTP 契约:`probing/server/API.md` + `tests/regression/spec/api_spec.json`。 **规则:** Skill 只通过 SQL / 文档化 HTTP 访问数据;**不含 Rust/Python 业务代码**。 -- **内容 SSOT:** `skills/`(wheel:`python/probing/bundled_skills/`)。 +- **内容 SSOT:** `python/probing/bundled_skills/`(仓库根 `skills/` 为指向该目录的符号链接别名)。 - **发现:** `python/probing/extensions/` entry point + `GET /apis/pythonext/skills/*`。 - **执行 SSOT:** `probing-skills` — CLI(`probing/cli/skill/`)、Web WASM (`web/src/agent/runner.rs`)、MCP(server 内 `run_skill` / `plan_skill`)。 @@ -214,8 +214,8 @@ HTTP 契约:`probing/server/API.md` + `tests/regression/spec/api_spec.json`。 禁止: L1 → 上层 L2 ↔ L2(采集器互调) - L2 → server / cli - probing-python → probing-cli — **打包耦合**(见 §4.1,非运行时采集→控制面) + L2 → server + L2 → probing-cli — 采集器不得依赖 CLI(wheel 的 `cli_main` 仅在根 `src/lib.rs` 组装;见 §4.1) skills → Rust 内部 web → probing-core ``` @@ -232,7 +232,7 @@ HTTP 契约:`probing/server/API.md` + `tests/regression/spec/api_spec.json`。 | **cli** | ✓ | opt | | | | — | | **web** | ✓ | | | | | | -### 4.1 PyPI 打包耦合(`probing-python` → `probing-cli`) +### 4.1 PyPI 打包耦合(cdylib → `probing-cli`) Maturin 只构建 **一个 native 产物**(根 `Cargo.toml` 的 `probing._core` cdylib)。PyPI 上的 `probing` 命令 **不是** 独立 Rust 二进制: @@ -248,12 +248,13 @@ pip install probing **契约(保持边界薄):** -- `probing-python` 依赖 `probing-cli` **仅** 用于在 `python_api.rs` 暴露 `cli_main` 给 PyO3。 +- 根 `src/lib.rs`(maturin cdylib 组装根)可调用 `probing_cli::pyo3::cli_main` 并导出为 + `_core.cli_main`。**`probing-python` 本身不依赖 `probing-cli`**。 - **禁止** 从采集器/server 引用 cli 的 inject、skill 内部、ctrl 等模块。 - 非 PyPI 安装仍可选用独立 Rust binary(`cli/src/main.rs`);PyPI 用户走 Python 入口。 若 CLI 继续膨胀,可拆 **`probing-cli-lib`**(共享 `cli_main` + HTTP 客户端),而不是让 -python ext 扩散 import cli crate 内部。 +采集器或 cdylib 扩散 import cli crate 内部。 --- @@ -329,11 +330,11 @@ sequenceDiagram | 问题 | 现状 | 目标 | |------|------|------| -| python → cli | `probing-python` → `probing-cli` | **可接受**(maturin wheel,仅 `cli_main`);禁止扩散 import | +| python → cli | ~~`probing-python` → `probing-cli`~~ | **已完成** — 无 crate 依赖;wheel `cli_main` 仅在根 `src/lib.rs` 组装(§4.1) | | python → cc | ~~`probing-python` → `probing-cc`~~ | **已完成** — `send_sigusr2_to_thread_id` 迁至 `probing-core::signal` | | core → NCCL/HCCL | ~~`probing-core` → nccl/hccl shim(`builtin-schema-docs`)供 `semantic_catalog`~~ | **已完成** — `register_docs()` 在 `server/engine.rs` 组装根调用;`probing-core` 默认 feature 为空 | | core → skills YAML | ~~`semantic_catalog` `include_str!(skills/semantic/tables.yaml)`~~ | **已完成** — overlay 在 `probing/core/resources/tables.yaml`;描述 SSOT 为 `docs` 注册表 | -| server → python features | ~~`server/profiling.rs`~~ 已删 | 火焰图走 Extension | +| server → python features | ~~`server/profiling.rs` / training 火焰图 handler~~ | **已完成** — 火焰图仅走 `torchextension` / `pprofextension` `ProbeExtensionCall` | | server → REPL 内部 | ~~`PythonRepl`~~ | `/ws` 仅用 `ReplSession` 门面 | | 组装集中 | 全在 server/engine.rs | 可选 extension manifest | | Skill 三份 loader | ~~Rust/Python/Web 编译期 embed~~ | **已完成** — `probing-skills` 为 loader/interpret/runner SSOT;Python 保留发现 entry point + PyO3 序列化桥;Web 从 API 反序列化为共享类型 | diff --git a/docs/src/design/profiling.md b/docs/src/design/profiling.md index cfdfb25c..3acd00a3 100644 --- a/docs/src/design/profiling.md +++ b/docs/src/design/profiling.md @@ -224,7 +224,7 @@ On-demand and CPU sampling share one pipeline: - **Main-thread HTTP path:** prefer the latest mixed snapshot. On macOS, when no native sample exists, briefly suspend the target through Mach, copy its PC/frame-pointer chain, resume it, and only then symbolize; this avoids delivering a signal to the training thread. **Never `SIGUSR2` the main tid while `sample_freq` is active** (Distributed included). Linux defaults to on-demand `SIGUSR2` with an alternate signal stack and bounded frame-pointer walk when sampling is off; `PROBING_STACK_SIGUSR2_MAIN=0` disables it. Cross-thread on-demand still uses `SIGUSR2`. - **Distributed flamegraph:** with sampling on, export only aggregated sampler buckets per rank (empty buckets → empty graph; no on-demand fallback). -TorchProbe module hooks are independent. Distributed CPU mixed-mode flamegraphs: `GET /apis/training/distributed_stack_flamegraph/json` (Web: **Stacks → Distributed**). Legacy torch module API `/apis/training/distributed_flamegraph/json` remains. +TorchProbe module hooks are independent. Distributed CPU mixed-mode flamegraphs: `GET /apis/pprofextension/flamegraph/distributed/json` (Web: **Stacks → Distributed**). SPMD torch module flamegraph: `GET /apis/torchextension/flamegraph/distributed/json`. ## System Metrics diff --git a/docs/src/design/profiling.zh.md b/docs/src/design/profiling.zh.md index 77b835fb..7240d864 100644 --- a/docs/src/design/profiling.zh.md +++ b/docs/src/design/profiling.zh.md @@ -227,7 +227,7 @@ configure("on,rate=0.5,layer_rate=0.3") - **分布式火焰图**:`sample_freq` 开启后只聚合各 rank 采样桶(为空则空图,不回退 on-demand);跨 rank merge 后每个 frame 携带 `ranks`。 - **canonicalize**:剥 `_Py_RunMain` / importlib / `platform.py` 等 bootstrap;SIGPROF 仅统计已注册 Python 主线程样本。 -TorchProbe 模块钩子与上述栈采集相互独立。分布式 CPU 混合栈火焰图见 `GET /apis/training/distributed_stack_flamegraph/json`(Web:**Stacks → Distributed**)。旧版 torch 模块级 API `/apis/training/distributed_flamegraph/json` 仍保留。 +TorchProbe 模块钩子与上述栈采集相互独立。分布式 CPU 混合栈火焰图见 `GET /apis/pprofextension/flamegraph/distributed/json`(Web:**Stacks → Distributed**)。SPMD torch 模块级火焰图:`GET /apis/torchextension/flamegraph/distributed/json`。 ## 系统指标 diff --git a/probing/extensions/python/src/extensions/torch.rs b/probing/extensions/python/src/extensions/torch.rs index 7595ba17..26b0b701 100644 --- a/probing/extensions/python/src/extensions/torch.rs +++ b/probing/extensions/python/src/extensions/torch.rs @@ -29,6 +29,19 @@ impl ProbeExtensionCall for TorchProbeExtension { let metric = params.get("metric").map(|s| s.as_str()); Ok(crate::features::torch::flamegraph_json(metric).into_bytes()) } + "flamegraph/distributed/json" => { + let cluster = params + .get("cluster") + .map(|v| v.as_str()) + .map(|v| v != "0" && v != "false") + .unwrap_or(true); + let step = params.get("step").and_then(|s| s.parse::().ok()); + let metric = params.get("metric").map(|s| s.as_str()); + crate::features::torch::collect_distributed_flamegraph_json(cluster, step, metric) + .await + .map(|body| body.into_bytes()) + .map_err(|e| EngineError::CallError(e.to_string())) + } _ => Err(EngineError::UnsupportedCall), } } diff --git a/probing/extensions/python/src/features/torch/mod.rs b/probing/extensions/python/src/features/torch/mod.rs index 9384b737..e6b6d530 100644 --- a/probing/extensions/python/src/features/torch/mod.rs +++ b/probing/extensions/python/src/features/torch/mod.rs @@ -922,6 +922,58 @@ fn distributed_torch_flamegraph_options(metric: TorchMetric, subtitle: &str) -> } } +/// SQL for one ``local_step`` snapshot of ``torch_trace`` across ranks (federated or local). +/// +/// Uses the row's training ``rank`` column (not federation ``_rank`` tags, which are added +/// after fan-out merge). ``COALESCE(_rank, …)`` breaks local ``python.torch_trace`` scans. +pub fn distributed_torch_trace_sql(table: &str, step: Option) -> String { + let step_pred = match step { + Some(s) => format!("local_step = {s}"), + None => format!("local_step = (SELECT max(local_step) FROM {table})"), + }; + format!( + r#" +SELECT + CAST(COALESCE(rank, 0) AS BIGINT) AS rank, + module, + stage, + CAST(duration AS DOUBLE) AS duration, + CAST(allocated_delta AS DOUBLE) AS allocated_delta, + CAST(max_allocated_delta AS DOUBLE) AS max_allocated_delta, + CAST(allocated AS DOUBLE) AS allocated, + CAST(max_allocated AS DOUBLE) AS max_allocated, + CAST(local_step AS BIGINT) AS local_step +FROM {table} +WHERE {step_pred} + AND module <> 'None' + AND (stage LIKE 'post %' OR stage LIKE 'pre %') +"# + ) +} + +/// Query ``python.torch_trace`` / ``global.python.torch_trace`` and render SPMD flamegraph JSON. +pub async fn collect_distributed_flamegraph_json( + cluster: bool, + step: Option, + metric: Option<&str>, +) -> anyhow::Result { + let table = if cluster { + "global.python.torch_trace" + } else { + "python.torch_trace" + }; + let sql = distributed_torch_trace_sql(table, step); + let engine = probing_core::ENGINE.read().await; + let dataframe = engine + .async_query(&sql) + .await + .context("distributed torch flamegraph query failed")? + .ok_or_else(|| { + anyhow::anyhow!("engine returned no dataframe for distributed torch query") + })?; + Ok(distributed_flamegraph_json_from_df(&dataframe, metric)) +} + /// JSON flamegraph from a federated or local ``torch_trace`` snapshot at one ``local_step``. pub fn distributed_flamegraph_json_from_df( data: &probing_proto::types::DataFrame, diff --git a/probing/server/API.md b/probing/server/API.md index f82e11e7..1a378011 100644 --- a/probing/server/API.md +++ b/probing/server/API.md @@ -27,8 +27,6 @@ Registered in `server/api/mod.rs`: | GET | `/apis/files?path=…` | Read workspace file | | GET/PUT | `/apis/nodes` | Cluster node list / register | | GET | `/apis/training/step_matrix` | Cross-rank train.step samples (`cluster=false` default; set `cluster=true` for on-demand fan-out) | -| GET | `/apis/training/distributed_flamegraph/json` | SPMD torch module flamegraph at one `local_step` (legacy; prefer distributed stack flamegraph) | -| GET | `/apis/training/distributed_stack_flamegraph/json` | Distributed CPU stack flamegraph (`?cluster=true` default, `?mode=mixed\|py`). Peer capture is bounded-concurrent with an overall deadline; completed peers are returned on timeout with `nodesFailed`. Frames may include `ranks: [i32]` (contributing training ranks under that partition) and payload `rankCount`. | | POST | `/apis/cluster/query` | On-demand SQL fan-out (`{"expr":"…","cluster":true}`; read-only SQL only) | Flamegraphs are served by profiler extensions (extension fallback, not public routes): @@ -37,10 +35,13 @@ Flamegraphs are served by profiler extensions (extension fallback, not public ro |--------|------|-------| | GET | `/apis/torchextension/flamegraph` | PyTorch module flamegraph (interactive HTML) | | GET | `/apis/torchextension/flamegraph/json` | JSON for native Web UI (`?metric=` optional) | +| GET | `/apis/torchextension/flamegraph/distributed/json` | SPMD torch module flamegraph at one `local_step` (`?cluster=true` default, `?step=`, `?metric=`) | | GET | `/apis/pprofextension/flamegraph` | CPU sampling flamegraph (interactive HTML) | | GET | `/apis/pprofextension/flamegraph/json` | JSON for native Web UI | | GET | `/apis/pprofextension/flamegraph/folded/json` | Raw folded stack lines for cluster merge | -| GET | `/apis/pprofextension/flamegraph/distributed/json` | Distributed SIGPROF stack flamegraph (`?cluster=true` default) | +| GET | `/apis/pprofextension/flamegraph/distributed/json` | Distributed SIGPROF stack flamegraph (`?cluster=true` default, `?mode=mixed\|py`). Peer capture is bounded-concurrent with an overall deadline; completed peers are returned on timeout with `nodesFailed`. Frames may include `ranks: [i32]` and payload `rankCount`. | + +Removed public aliases (see `api_spec.json` `deprecated_paths`): `/apis/training/distributed_flamegraph/json`, `/apis/training/distributed_stack_flamegraph/json`. ## Cluster query (on-demand fan-out) @@ -72,8 +73,8 @@ All handlers live in `python/probing/handlers/pythonext.py`, one canonical local | GET | `/apis/pythonext/trace/variables` | `trace/variables` | | GET | `/apis/pythonext/trace/chrome-tracing` | `trace/chrome-tracing` | | GET | `/apis/pythonext/pytorch/timeline` | `pytorch/timeline` | -| GET | `/apis/pythonext/pytorch/profile` | `pytorch/profile` — start profiler (legacy) | -| GET | `/apis/pythonext/pytorch/profile/start` | `pytorch/profile/start` — `steps`, `trigger` | +| GET | `/apis/pythonext/pytorch/profile` | `pytorch/profile` — start profiler (legacy alias of `profile/start`; prefer `/start`) | +| GET | `/apis/pythonext/pytorch/profile/start` | `pytorch/profile/start` — `steps`, `trigger` (canonical; Web clients use this) | | GET | `/apis/pythonext/pytorch/profile/stop` | `pytorch/profile/stop` — finalize capture | | GET | `/apis/pythonext/pytorch/profile/status` | `pytorch/profile/status` | | GET | `/apis/pythonext/pytorch/runtime-debug?include_values=` | `pytorch/runtime-debug` — local wait counters + read-only job TCPStore snapshot | diff --git a/probing/server/src/server/api/mod.rs b/probing/server/src/server/api/mod.rs index 79a1db89..c60897cc 100644 --- a/probing/server/src/server/api/mod.rs +++ b/probing/server/src/server/api/mod.rs @@ -20,8 +20,6 @@ pub const PUBLIC_API_ROUTES: &[(&str, &str)] = &[ ("GET", "/nodes"), ("PUT", "/nodes"), ("GET", "/training/step_matrix"), - ("GET", "/training/distributed_flamegraph/json"), - ("GET", "/training/distributed_stack_flamegraph/json"), ("POST", "/cluster/query"), ("GET", "/processes/local"), ("POST", "/query/local-pid"), @@ -39,14 +37,6 @@ fn public_routes() -> Router { .route("/files", get(file_api::read_file)) .route("/nodes", get(cluster::get_nodes).put(cluster::put_node)) .route("/training/step_matrix", get(training::get_step_matrix)) - .route( - "/training/distributed_flamegraph/json", - get(training::get_distributed_flamegraph_json), - ) - .route( - "/training/distributed_stack_flamegraph/json", - get(training::get_distributed_stack_flamegraph_json), - ) .route("/cluster/query", post(cluster_query::post_cluster_query)) .route("/processes/local", get(system::get_local_processes_json)) .route("/query/local-pid", post(local_query::query_local_pid)) diff --git a/probing/server/src/server/training.rs b/probing/server/src/server/training.rs index c81cf62f..5ba857c4 100644 --- a/probing/server/src/server/training.rs +++ b/probing/server/src/server/training.rs @@ -313,115 +313,6 @@ fn ele_as_f64(v: Option) -> f64 { } } -#[derive(Debug, Deserialize)] -pub struct DistributedFlamegraphParams { - pub step: Option, - pub metric: Option, - pub cluster: Option, -} - -/// SQL for one ``local_step`` snapshot of ``torch_trace`` across ranks (federated or local). -/// -/// Uses the row's training ``rank`` column (not federation ``_rank`` tags, which are added -/// after fan-out merge). ``COALESCE(_rank, …)`` breaks local ``python.torch_trace`` scans. -pub fn distributed_torch_trace_sql(table: &str, step: Option) -> String { - let step_pred = match step { - Some(s) => format!("local_step = {s}"), - None => format!("local_step = (SELECT max(local_step) FROM {table})"), - }; - format!( - r#" -SELECT - CAST(COALESCE(rank, 0) AS BIGINT) AS rank, - module, - stage, - CAST(duration AS DOUBLE) AS duration, - CAST(allocated_delta AS DOUBLE) AS allocated_delta, - CAST(max_allocated_delta AS DOUBLE) AS max_allocated_delta, - CAST(allocated AS DOUBLE) AS allocated, - CAST(max_allocated AS DOUBLE) AS max_allocated, - CAST(local_step AS BIGINT) AS local_step -FROM {table} -WHERE {step_pred} - AND module <> 'None' - AND (stage LIKE 'post %' OR stage LIKE 'pre %') -"# - ) -} - -/// SPMD flamegraph: same ``local_step``, all ranks — identical stacks merge, divergent paths split. -pub async fn get_distributed_flamegraph_json( - axum::extract::Query(params): axum::extract::Query, -) -> impl IntoResponse { - use axum::http::header; - - if let Some(msg) = crate::engine_lifecycle::engine_not_ready_message() { - return ApiError::service_unavailable(msg).into_response(); - } - let cluster = params.cluster.unwrap_or(true); - let table = if cluster { - "global.python.torch_trace" - } else { - "python.torch_trace" - }; - let sql = distributed_torch_trace_sql(table, params.step); - - let fanout = match cluster_fanout::fanout_query( - &sql, - cluster, - false, - cluster_fanout::ClusterFanoutScope::Auto, - ) - .await - { - Ok(f) => f, - Err(err) => return ApiError::from(err).into_response(), - }; - let partial = fanout.meta.partial; - let body = probing_python::features::torch::distributed_flamegraph_json_from_df( - &fanout.dataframe, - params.metric.as_deref(), - ); - let status = if partial { - StatusCode::SERVICE_UNAVAILABLE - } else { - StatusCode::OK - }; - (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() -} - -#[derive(Debug, Deserialize)] -pub struct DistributedStackFlamegraphParams { - pub cluster: Option, - /// `mixed` (default) = Python + native; `py` = Python frames only. - pub mode: Option, -} - -/// Distributed CPU stack flamegraph: merge SIGPROF folded stacks across cluster ranks. -pub async fn get_distributed_stack_flamegraph_json( - axum::extract::Query(params): axum::extract::Query, -) -> impl IntoResponse { - use axum::http::header; - - if let Some(msg) = crate::engine_lifecycle::engine_not_ready_message() { - return ApiError::service_unavailable(msg).into_response(); - } - - let cluster = params.cluster.unwrap_or(true); - let mode = params.mode.as_deref().unwrap_or("mixed"); - let (body, partial) = - probing_python::features::stacktrace::tracers::pprof::collect_distributed_stack_flamegraph_json( - cluster, mode, - ) - .await; - let status = if partial { - StatusCode::SERVICE_UNAVAILABLE - } else { - StatusCode::OK - }; - (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() -} - #[cfg(test)] mod tests { use super::*; @@ -433,17 +324,6 @@ mod tests { assert!(sql_has_limit(STEP_MATRIX_SQL)); } - #[test] - fn distributed_torch_sql_valid_for_global_fanout() { - use probing_core::core::federation::validate_global_query; - let sql = super::distributed_torch_trace_sql("global.python.torch_trace", Some(42)); - assert!(sql.contains("local_step = 42")); - assert!(sql.contains("COALESCE(rank, 0)")); - assert!(validate_global_query(&sql).is_ok()); - let latest = super::distributed_torch_trace_sql("global.python.torch_trace", None); - assert!(latest.contains("max(local_step)")); - } - #[test] fn aggregate_normalizes_legacy_single_process_rank() { let rows = vec![RawStepRow { diff --git a/python/probing/_skills.README.md b/python/probing/_skills.README.md index 8ca02e38..3cbd01ad 100644 --- a/python/probing/_skills.README.md +++ b/python/probing/_skills.README.md @@ -1,14 +1,14 @@ -# Bundled diagnostic skills (wheel copy) +# Bundled diagnostic skills -**Do not edit skill content here.** This tree is generated from repo-root -[`skills/`](../../../skills/README.md): +Skill **data** lives in [`bundled_skills/`](bundled_skills/README.md) (SSOT, packaged in the wheel). +Repo-root [`skills/`](../../../skills) is a **symlink** to `bundled_skills/` for authoring ergonomics +(`./skills/install.sh`, docs, L4 layout). ```bash -make wheel # bundles skills/ and web/dist/ automatically +# Edit either path — they are the same directory: +ls skills/catalog.yaml +ls python/probing/bundled_skills/catalog.yaml ``` -Shipped inside the PyPI wheel so `probing skill list` and install tooling work -without a git checkout. At runtime, repo / user / project skill dirs can override -these entries (see [`../skills/README.md`](../skills/README.md)). - -Python loader code lives in [`../skills/`](../skills/README.md) (`probing.skills` package). +Python loader / install code lives in [`skills/`](skills/README.md) (`probing.skills` package) — +that is a different folder from `bundled_skills/`. diff --git a/tests/regression/profiling/test_distributed_flamegraph.py b/tests/regression/profiling/test_distributed_flamegraph.py index b3d172dd..6bada419 100644 --- a/tests/regression/profiling/test_distributed_flamegraph.py +++ b/tests/regression/profiling/test_distributed_flamegraph.py @@ -131,8 +131,13 @@ def test_api_spec_lists_distributed_endpoint(self): spec_path = Path(__file__).resolve().parents[1] / "spec" / "api_spec.json" spec = json.loads(spec_path.read_text(encoding="utf-8")) - paths = {(r["method"], r["path"]) for r in spec["server_public"]} - assert ("GET", "/apis/training/distributed_flamegraph/json") in paths + ext_paths = {(r["method"], r["path"]) for r in spec["other_extensions"]} + assert ("GET", "/apis/torchextension/flamegraph/distributed/json") in ext_paths + assert ("GET", "/apis/pprofextension/flamegraph/distributed/json") in ext_paths + + deprecated = {item["path"] for item in spec["deprecated_paths"]} + assert "/apis/training/distributed_flamegraph/json" in deprecated + assert "/apis/training/distributed_stack_flamegraph/json" in deprecated def test_web_client_declares_distributed_stack_path(self): from pathlib import Path @@ -144,11 +149,7 @@ def test_web_client_declares_distributed_stack_path(self): if entry["source"] != "web/src/api/stack.rs": continue stack_calls.extend(c["path"] for c in entry["calls"]) - assert "/apis/training/distributed_stack_flamegraph/json" in stack_calls - - # Legacy torch SPMD endpoint remains server-public; Web UI uses stack flamegraph. - paths = {(r["method"], r["path"]) for r in spec["server_public"]} - assert ("GET", "/apis/training/distributed_flamegraph/json") in paths + assert "/apis/pprofextension/flamegraph/distributed/json" in stack_calls @staticmethod def _normalize_profiling_view(view: str) -> str: diff --git a/tests/regression/rust/probing/server/training_observability_tests.rs b/tests/regression/rust/probing/server/training_observability_tests.rs index 3553cbda..9bb99d80 100644 --- a/tests/regression/rust/probing/server/training_observability_tests.rs +++ b/tests/regression/rust/probing/server/training_observability_tests.rs @@ -65,7 +65,7 @@ fn cluster_query_request_roundtrip() { #[test] fn distributed_torch_sql_valid_for_global_fanout() { use probing_core::core::federation::validate_global_query; - use probing_server::server::training::distributed_torch_trace_sql; + use probing_python::features::torch::distributed_torch_trace_sql; let sql = distributed_torch_trace_sql("global.python.torch_trace", Some(42)); assert!(sql.contains("local_step = 42")); @@ -116,15 +116,3 @@ fn distributed_flamegraph_json_contract_from_dataframe() { .unwrap_or(false)); assert_eq!(payload["total"], 4_000_000); } - -#[test] -fn distributed_flamegraph_params_roundtrip() { - use probing_server::server::training::DistributedFlamegraphParams; - - let params: DistributedFlamegraphParams = - serde_json::from_str(r#"{"step":5,"metric":"peak_mb","cluster":false}"#) - .expect("deserialize"); - assert_eq!(params.step, Some(5)); - assert_eq!(params.metric.as_deref(), Some("peak_mb")); - assert_eq!(params.cluster, Some(false)); -} diff --git a/tests/regression/spec/api_spec.json b/tests/regression/spec/api_spec.json index 7c12e95a..b71a9cc4 100644 --- a/tests/regression/spec/api_spec.json +++ b/tests/regression/spec/api_spec.json @@ -50,14 +50,6 @@ "method": "GET", "path": "/apis/training/step_matrix" }, - { - "method": "GET", - "path": "/apis/training/distributed_flamegraph/json" - }, - { - "method": "GET", - "path": "/apis/training/distributed_stack_flamegraph/json" - }, { "method": "POST", "path": "/apis/cluster/query" @@ -403,6 +395,16 @@ "cors": false } }, + { + "extension_name": "torchextension", + "method": "GET", + "path": "/apis/torchextension/flamegraph/distributed/json", + "local_path": "flamegraph/distributed/json", + "response": { + "content_type": "application/json", + "cors": false + } + }, { "extension_name": "pprofextension", "method": "GET", @@ -457,23 +459,38 @@ "deprecated_paths": [ { "path": "/apis/flamegraph/torch", - "replacement": "/apis/torchextension/flamegraph" + "replacement": "/apis/torchextension/flamegraph", + "status": "removed" }, { "path": "/apis/flamegraph/pprof", - "replacement": "/apis/pprofextension/flamegraph" + "replacement": "/apis/pprofextension/flamegraph", + "status": "removed" }, { "path": "/apis/python/ray/timeline", - "replacement": "/apis/pythonext/ray/timeline" + "replacement": "/apis/pythonext/ray/timeline", + "status": "removed" }, { "path": "/apis/python/ray/timeline/chrome", - "replacement": "/apis/pythonext/ray/timeline/chrome" + "replacement": "/apis/pythonext/ray/timeline/chrome", + "status": "removed" }, { "path": "/apis/pythonext/flamegraph", - "replacement": "/apis/torchextension/flamegraph" + "replacement": "/apis/torchextension/flamegraph", + "status": "removed" + }, + { + "path": "/apis/training/distributed_flamegraph/json", + "replacement": "/apis/torchextension/flamegraph/distributed/json", + "status": "removed" + }, + { + "path": "/apis/training/distributed_stack_flamegraph/json", + "replacement": "/apis/pprofextension/flamegraph/distributed/json", + "status": "removed" } ], "client_contracts": { @@ -531,7 +548,7 @@ }, { "method": "GET", - "path": "/apis/training/distributed_stack_flamegraph/json" + "path": "/apis/pprofextension/flamegraph/distributed/json" } ] }, @@ -565,7 +582,7 @@ }, { "method": "GET", - "path": "/apis/pythonext/pytorch/profile" + "path": "/apis/pythonext/pytorch/profile/start" } ] }, diff --git a/web/DESIGN.md b/web/DESIGN.md index 58df9767..5d0eb83e 100644 --- a/web/DESIGN.md +++ b/web/DESIGN.md @@ -4,31 +4,25 @@ **技术栈**:Dioxus 0.7(WASM)、dioxus-router、Tailwind(dx 构建)、reqwest、`probing-skills`(技能执行 SSOT)、async-openai(浏览器 BYOK LLM)。 -## 双 UI 渐进迁移 +## 应用根 -`main.rs` 通过 `ui_version.rs::RootApp` 只挂载一个应用根: +`main.rs` 只挂载 `next::NextApp`。历史上的 Classic UI(`app.rs` / `pages/` / `ui_version`)已删除;产品界面只有这一套。 -- `next`(默认):`next::NextApp`,独立 Router、Shell、信息架构和诊断首页。 -- `classic`(冻结回退):原有 `app::App`,路由和组件保持不变。 - -未保存过界面偏好的用户直接进入 Next。已有偏好继续生效;Classic 用户可通过右下角入口进入 Next, -Next 用户通过侧栏底部切回 Classic,也可使用 `?ui=classic|next` 手动切换。选择保存在 -`localStorage["probing.ui.version"]`。切换时整页重载,避免两个 Router、hook -和全局监听器同时存在。 - -Next UI 代码边界: +代码边界: ```text web/src/next/ -├── routes.rs # 独立 NextRoute +├── routes.rs # NextRoute(产品 URL) ├── page_registry.rs # 页面身份、布局、侧栏分组、证据与调查上下文合同 -├── shell.rs # 诊断优先的导航与任务上下文 -├── components.rs # Next 专用页面原语 -├── model.rs # 首页/分布式健康派生模型 -└── pages/ # Dashboard、Investigate、Training、Distributed、Profiles、Explore +├── shell.rs # 诊断优先的导航与任务上下文 +├── components.rs # 页面原语(WorkspacePage / EvidenceSurface …) +├── evidence.rs # EvidenceRequest / Receipt +├── page_snapshot.rs # 页面向 Agent 发布的证据快照 +├── sidebar.rs # 功能轨道 + 详情控制区 +└── pages/ # Dashboard、Investigate、Training、… ``` -Next Router 保持 Classic 产品 URL 的兼容性,并在新壳层中直接承载成熟能力: +共享能力(HTTP、火焰图、Agent skill runner、overlays)留在 `web/src/{api,components,agent,state}/`,由 Next 消费。 | 工作区 | 路由 | |--------|------| @@ -37,32 +31,9 @@ Next Router 保持 Classic 产品 URL 的兼容性,并在新壳层中直接承 | 证据 | `/spans`、`/stacks/*`、`/profiles`、`/profiling/:view` | | 工具 | `/analytics`、`/python`、`/pulsing`、`/cluster`、`/system` | -Next Shell 同时挂载通过 `⌘K` 唤起的 Command Panel、全局快捷键、Investigation -URL 同步、页面发布的 evidence snapshot、后台任务与 Torch overhead monitor,以及可浮动的 -Investigate 面板。主内容区不设置固定顶栏,运行上下文和操作由当前页面按需承载。 -低频命令输入不常驻占用页面纵向空间。 -Classic 继续作为独立应用保留;已知产品路由不再依赖 Classic fallback。 - -### 迁移边界与顺序 - -Next 页面采用平行迁移,不在 Classic 页面中增加 Next 分支: - -- `src/pages/` 是 Classic 冻结基线;迁移期间只接受 Classic 自身的阻断性修复。 -- `src/next/pages/` 拥有 Next 页面、状态组合和交互。 -- `src/api/`、协议 DTO 与纯派生模型可以共享;已完成迁移的 Next 页面不得再挂载 Classic 页面组件。 -- 所有已知产品路由已解除 Classic 页面挂载;`ClassicFallback` 只承接未知或历史 URL,并引导用户回到 Next 能力目录。 - -迁移台账: +Shell 挂载 ⌘K Command Panel、全局快捷键、Investigation URL 同步、页面 evidence snapshot、后台任务与 Torch overhead monitor,以及可浮动的 Investigate 面板。主内容区不设固定顶栏。未知 URL 由 `ClassicFallback`(历史命名)落到能力目录引导页,不再切回旧 UI。 -| 顺序 | 工作区 | 当前状态 | -|------|--------|----------| -| 1 | Dashboard | Next 原生 | -| 2 | Cluster Overview / Nodes / Distributed Status | Next 原生 | -| 3 | Training / Inference / RL | Next 原生 | -| 4 | Profiling / Stacks / Tracing | 已在 Next 原生实现 | -| 5 | Analytics / Python Trace / Pulsing / System | 已在 Next 原生实现 | -| 6 | Investigate | Next 原生;保留与 Skill/Agent 合同的持续能力对照 | -| 7 | Shell / Classic retirement | Next 已成为默认入口;Classic 继续冻结并保留显式回退,待独立退休阶段删除 | +**新页面**放在 `web/src/next/pages/`,在 `routes.rs` 注册,并在 `page_registry.rs` 声明 page spec。 --- @@ -88,18 +59,18 @@ Probing Web 是 **训练/推理现场的 live 诊断工作台**,不是 experim ### 1.2 全局壳层(非路由) -挂载于 `App` 根节点或 `AppLayout`,任意页面可用: +挂载于 `NextApp` / `NextShell`,任意页面可用: | 组件 | 快捷键 / 触发 | 职责 | |------|----------------|------| | `AppOverlays` | 侧栏 Monitors 点击 / `file:line` | 根级 viewport overlay(任务队列、Torch overhead、源码预览) | | `GlobalCommandPanel` | 侧栏搜索 / ⌘K | SQL / eval REPL;不常驻输入条 | -| `AgentPanel` | ⌘J(`/agent` 全页时禁用浮层) | 右侧浮层 Agent | -| `InvestigationContextHint` | 页内(有上下文时) | 轻量提示条 + 跳转 Spans | +| `AgentPanel` | ⌘J(`/agent` 全页时禁用浮层) | 右侧浮层 Investigate | +| (已移除)InvestigationContextHint | — | 调查上下文由 Next 页内固定条承载 | | `SidebarMonitors` | — | 侧栏底部紧凑摘要(Tasks + Torch overhead);点击打开对应 overlay | | `LlmSettingsOverlay` | Agent ⚙ | LLM API 配置(localStorage) | | `ShortcutsHelpOverlay` | `?` | 快捷键帮助 | -| `PageContextSync` | 路由变更 | 同步 `PAGE_CONTEXT`、拉 page snapshot | +| `PageContextSync` | — | 已由 `NextShell` + `page_snapshot` 取代 | | `InvestigationUrlSync` | — | 上下文 ↔ URL query 双向同步 | | `UiTaskRuntime` | — | 全局任务计时 tick | @@ -191,7 +162,7 @@ Cluster ├── Overview ── Workloads: Training / Inference / RL ── Advanced: Profiling / Stacks / Tracing ── Deep tools -Tasks / Overhead / Classic 紧凑状态行 +Tasks / Overhead 紧凑状态行 ``` 活动路径同时承载当前页面的控制项,例如刷新、数据范围、cluster fan-out、 @@ -200,18 +171,6 @@ Tasks / Overhead / Classic 紧凑状态行 因此不存在“点击标题究竟是导航还是展开”的混合语义,也不使用 `More` hover 面板。桌面侧栏支持 288px 控制模式和 56px 图标模式。 -Classic 侧栏保持原结构: - -```text -Logo -├── Overview: Dashboard, Investigate, Stacks▾ -├── Analysis: Profiling▾, Analytics, Spans, Training, Pulsing -├── System: Cluster, Python -nav(flex-1 滚动) -Monitors: Background tasks · Torch overhead(摘要行,点击打开 overlay) -GitHub footer -``` - ### 2.5 键盘快捷键(`keyboard_shortcuts.rs`) | 键 | 动作 | @@ -288,10 +247,10 @@ Web **不嵌入** skill YAML;启动时从 probing server 拉取,执行走共 | 模块 | 路径 | 职责 | |------|------|------| -| UI | `components/agent/` | `chat.rs`(全页 + 浮层)、`step_card.rs`、`panel.rs`、`settings.rs` | +| UI | `next/pages/investigate.rs` + `components/agent/chat.rs` | Investigate 会话 UI + skill runner | | 加载 | `agent/skill.rs` | 内存 store;`populate_skill_store` | | API | `api/skills.rs` | `GET /apis/pythonext/skills/routing` + per-id `load` | -| 路由 | `agent/routing.rs` | `Route` → page_id;catalog / intents / pages 来自 server | +| 路由 | `agent/routing.rs` | skill catalog / intents(供 LLM) | | 执行 | `agent/runner.rs` | `run_skill` → `probing_skills::run_step` | | 后端 | `agent/skills_backend.rs` | `WebBackend`:`POST /query`、`/apis/cluster/query`、GET API | | 解释 | `agent/interpret.rs` | 桥接 `probing-skills` interpret 类型 | @@ -301,10 +260,10 @@ Web **不嵌入** skill YAML;启动时从 probing server 拉取,执行走共 **数据流**: ```text -AppLayout mount → ApiClient::load_skill_store() +NextShell mount → ApiClient::load_skill_store() → /apis/pythonext/skills/routing + /load?id=… → agent/skill.rs STORE -Agent chip / LLM → resolve_skill_id → run_skill(WebBackend) +Investigate chip / LLM → resolve_skill_id → run_skill(WebBackend) → probing-skills runner (与 CLI / MCP 同语义) ``` @@ -316,56 +275,18 @@ Skill 内容 SSOT:仓库根 `skills/`(wheel:`python/probing/bundled_skills ``` web/src/ -├── main.rs # WASM 入口;支持 base_path 子路径部署 -├── app.rs # Route 枚举 + 页面包 AppLayout + App 根 AppOverlays -├── api/ # ApiClient 与分域 endpoint(含 skills、overhead) +├── main.rs # WASM 入口 → next::NextApp +├── next/ # 产品 UI(路由、壳、页面、证据合同) +├── api/ # ApiClient 与分域 endpoint ├── agent/ # Skill 加载、LLM、runner、WebBackend、routing -├── overhead/ # Torch overhead 领域逻辑(metrics + SQL,无 UI) -├── hooks/mod.rs # use_app_resource(首选)、use_api(遗留)、poll 辅助 -├── pages/ # 业务页 -│ ├── dashboard.rs -│ ├── agent.rs -│ ├── profiling.rs -│ ├── traces.rs # Spans(/spans、/traces redirect) -│ ├── training.rs -│ ├── analytics.rs -│ ├── stack.rs -│ ├── python/ -│ ├── pulsing.rs -│ └── cluster.rs -├── state/ -│ ├── investigation.rs -│ ├── investigation_url.rs -│ ├── page_context.rs -│ ├── ui_tasks.rs -│ ├── overlays.rs # APP_OVERLAY 状态机 -│ ├── scroll_lock.rs # overlay 打开时锁定 body 滚动 -│ ├── profiling.rs -│ ├── stack.rs -│ ├── agent.rs -│ ├── profile_snapshots.rs -│ ├── sidebar.rs -│ ├── commands.rs -│ ├── llm_config.rs -│ └── source_viewer.rs # 薄封装,转发 overlays API -├── components/ -│ ├── layout.rs # AppLayout 壳 -│ ├── app_overlays.rs # Tasks / Overhead / SourceViewer 根渲染 -│ ├── overlay_shell.rs # 居中 modal 壳 -│ ├── sidebar/ # 导航、Monitors、Profiling/Stack 子菜单、resize -│ ├── overhead/ # TorchOverheadPanel(UI) -│ ├── workspace/ -│ ├── agent/ -│ ├── flamegraph/ -│ ├── timeline_viewer/ -│ ├── source_viewer.rs -│ ├── global_command_panel.rs -│ ├── investigation_context_hint.rs -│ ├── profile_snapshot_bar.rs -│ ├── page_context_sync.rs -│ ├── ui_task_runtime.rs -│ ├── keyboard_shortcuts.rs -│ └── ... +├── overhead/ # Torch overhead 领域逻辑(metrics + SQL) +├── hooks/mod.rs # use_app_resource、poll 辅助 +├── state/ # GlobalSignal(investigation、page_context、agent…) +├── components/ # 共享可视化与 overlays(flamegraph、timeline、agent runner) +│ ├── app_overlays.rs +│ ├── profiling_controls/ # 侧栏采集控件(被 next/sidebar 引用) +│ ├── agent/ # skill runner helpers + LLM settings +│ └── … └── utils/ ``` @@ -373,7 +294,7 @@ web/src/ | 主题 | 约定 | |------|------| -| **新页面** | 放 `pages/`,在 `app.rs` 注册 `Route` + 路由组件 | +| **新页面** | 放 `next/pages/`,在 `next/routes.rs` 注册,并更新 `page_registry.rs` | | **跨页状态** | `state/` GlobalSignal;避免在 render 分支内 `write()`(用 `use_effect`) | | **拉数** | 新代码用 `use_app_resource` + `AsyncBoundary`;`use_api` 仅遗留页(如 Pulsing) | | **样式** | `colors.rs` 常量 > 硬编码 Tailwind | @@ -386,8 +307,8 @@ web/src/ | 模块 | 职责 | |------|------| -| `layout` | 侧栏、运行上下文、主内容区、Agent 浮层容器;启动 `load_skill_store` | -| `sidebar` | 导航、Monitors 摘要、Profiling/Stack 控件、resize | +| `next/shell` | 侧栏、主内容、Investigate 浮层、page evidence 发布 | +| `next/sidebar` | 功能轨道、详情控制、Profiling/Stack 控件 | | `app_overlays` | 根级 Tasks / Overhead / SourceViewer | | `flamegraph` | pprof / torch 火焰图、diff | | `timeline_viewer` | trace / pytorch / ray timeline | @@ -402,10 +323,10 @@ web/src/ ## 八、路由一览 -| 路由 | 页面 | AppLayout | -|------|------|-----------| +| 路由 | 页面 | 布局 | +|------|------|------| | `/` | Dashboard | 标准 | -| `/agent` | Investigate(全页 Agent) | 标准 | +| `/agent` | Investigate | 标准 | | `/distributed` | Cluster Overview | 标准 | | `/cluster` | Cluster Nodes | 标准 | | `/cluster/status` | Distributed Status(Wait Counters / Rendezvous) | 标准 | @@ -413,10 +334,10 @@ web/src/ | `/profiling`, `/profiling/:view` | Profiling | **fullscreen** | | `/analytics` | Analytics SQL | 标准 | | `/python` | Python variable trace | 标准 | -| `/spans`, `/traces` | Spans(`/traces` → redirect) | **fullscreen** | -| `/training` | Training 热力图 / collective | 标准 | +| `/spans`, `/traces` | Spans(`/traces` 兼容别名) | **fullscreen** | +| `/training` | Training 证据平面 | 标准 | | `/pulsing` | Pulsing actors | 标准 | -| `/chrome-tracing` | → redirect `/profiling/trace` | — | +| `/chrome-tracing` | → `/profiling/trace` | — | --- @@ -431,7 +352,7 @@ web/src/ 以下为当前实现与理想状态之间的差距,供迭代参考(非阻塞发布): -**已修复(历史 P0)**:`pages/stack.rs` 侧栏帧计数改为 `use_effect`;删除 `chrome_tracing_iframe`;Playbook 体系迁移为 Skills + `probing-skills`。 +**已修复(历史 P0)**:旧 Classic `pages/stack.rs` 侧栏帧计数改为 `use_effect`;删除 `chrome_tracing_iframe`;Playbook 体系迁移为 Skills + `probing-skills`。 **重构后已落地**:`SidebarMonitors` + `AppOverlays`;`overhead/` 领域模块;runtime skill 加载;`OverlayShell` 统一 modal。 diff --git a/web/assets/tailwind.css b/web/assets/tailwind.css index e8003625..e16894ed 100644 --- a/web/assets/tailwind.css +++ b/web/assets/tailwind.css @@ -554,40 +554,6 @@ video { display: none; } -.container { - width: 100%; -} - -@media (min-width: 640px) { - .container { - max-width: 640px; - } -} - -@media (min-width: 768px) { - .container { - max-width: 768px; - } -} - -@media (min-width: 1024px) { - .container { - max-width: 1024px; - } -} - -@media (min-width: 1280px) { - .container { - max-width: 1280px; - } -} - -@media (min-width: 1536px) { - .container { - max-width: 1536px; - } -} - .markdown-body { font-size: 0.875rem; line-height: 1.25rem; @@ -865,10 +831,6 @@ video { bottom: 0px; } -.bottom-1 { - bottom: 0.25rem; -} - .bottom-1\/2 { bottom: 50%; } @@ -877,10 +839,6 @@ video { bottom: 0.75rem; } -.bottom-4 { - bottom: 1rem; -} - .bottom-6 { bottom: 1.5rem; } @@ -905,10 +863,6 @@ video { left: 0.5rem; } -.left-2\.5 { - left: 0.625rem; -} - .left-3 { left: 0.75rem; } @@ -917,10 +871,6 @@ video { left: 75%; } -.left-4 { - left: 1rem; -} - .right-0 { right: 0px; } @@ -933,10 +883,6 @@ video { right: 0.75rem; } -.right-4 { - right: 1rem; -} - .right-6 { right: 1.5rem; } @@ -1021,10 +967,6 @@ video { z-index: 50; } -.z-\[10050\] { - z-index: 10050; -} - .z-\[100\] { z-index: 100; } @@ -1033,10 +975,6 @@ video { z-index: 1; } -.z-\[60\] { - z-index: 60; -} - .z-\[9996\] { z-index: 9996; } @@ -1086,10 +1024,6 @@ video { margin-bottom: 0.5rem; } -.-mb-px { - margin-bottom: -1px; -} - .-mt-0\.5 { margin-top: -0.125rem; } @@ -1098,10 +1032,6 @@ video { margin-bottom: 0.25rem; } -.mb-1\.5 { - margin-bottom: 0.375rem; -} - .mb-2 { margin-bottom: 0.5rem; } @@ -1114,10 +1044,6 @@ video { margin-bottom: 1rem; } -.mb-6 { - margin-bottom: 1.5rem; -} - .ml-0\.5 { margin-left: 0.125rem; } @@ -1134,22 +1060,10 @@ video { margin-left: 0.5rem; } -.ml-3 { - margin-left: 0.75rem; -} - -.ml-4 { - margin-left: 1rem; -} - .ml-5 { margin-left: 1.25rem; } -.ml-6 { - margin-left: 1.5rem; -} - .ml-auto { margin-left: auto; } @@ -1158,14 +1072,6 @@ video { margin-right: 0.25rem; } -.mr-1\.5 { - margin-right: 0.375rem; -} - -.mr-4 { - margin-right: 1rem; -} - .mt-0\.5 { margin-top: 0.125rem; } @@ -1265,14 +1171,6 @@ video { height: 0.625rem; } -.h-20 { - height: 5rem; -} - -.h-24 { - height: 6rem; -} - .h-3 { height: 0.75rem; } @@ -1321,18 +1219,10 @@ video { height: 12px; } -.h-\[20px\] { - height: 20px; -} - .h-\[22px\] { height: 22px; } -.h-\[28px\] { - height: 28px; -} - .h-\[7px\] { height: 7px; } @@ -1353,10 +1243,6 @@ video { max-height: 6rem; } -.max-h-32 { - max-height: 8rem; -} - .max-h-40 { max-height: 10rem; } @@ -1365,10 +1251,6 @@ video { max-height: 12rem; } -.max-h-56 { - max-height: 14rem; -} - .max-h-64 { max-height: 16rem; } @@ -1381,10 +1263,6 @@ video { max-height: 24rem; } -.max-h-\[320px\] { - max-height: 320px; -} - .max-h-\[420px\] { max-height: 420px; } @@ -1405,22 +1283,10 @@ video { max-height: 80vh; } -.max-h-\[90vh\] { - max-height: 90vh; -} - .max-h-\[calc\(100\%-1\.5rem\)\] { max-height: calc(100% - 1.5rem); } -.max-h-\[calc\(100vh-14rem\)\] { - max-height: calc(100vh - 14rem); -} - -.max-h-\[min\(28rem\2c 50vh\)\] { - max-height: min(28rem,50vh); -} - .max-h-\[min\(60vh\2c 520px\)\] { max-height: min(60vh,520px); } @@ -1445,10 +1311,6 @@ video { min-height: 2.25rem; } -.min-h-\[140px\] { - min-height: 140px; -} - .min-h-\[200px\] { min-height: 200px; } @@ -1457,18 +1319,10 @@ video { min-height: 24rem; } -.min-h-\[34px\] { - min-height: 34px; -} - .min-h-\[36px\] { min-height: 36px; } -.min-h-\[4rem\] { - min-height: 4rem; -} - .min-h-\[520px\] { min-height: 520px; } @@ -1477,22 +1331,10 @@ video { min-height: 600px; } -.min-h-\[680px\] { - min-height: 680px; -} - -.min-h-\[calc\(100vh-11rem\)\] { - min-height: calc(100vh - 11rem); -} - .min-h-full { min-height: 100%; } -.w-1 { - width: 0.25rem; -} - .w-1\.5 { width: 0.375rem; } @@ -1533,10 +1375,6 @@ video { width: 6rem; } -.w-28 { - width: 7rem; -} - .w-3 { width: 0.75rem; } @@ -1549,14 +1387,6 @@ video { width: 1rem; } -.w-40 { - width: 10rem; -} - -.w-44 { - width: 11rem; -} - .w-5 { width: 1.25rem; } @@ -1597,10 +1427,6 @@ video { width: 33vw; } -.w-\[360px\] { - width: 360px; -} - .w-\[38\%\] { width: 38%; } @@ -1633,10 +1459,6 @@ video { min-width: 18rem; } -.min-w-\[100px\] { - min-width: 100px; -} - .min-w-\[140px\] { min-width: 140px; } @@ -1681,10 +1503,6 @@ video { min-width: 480px; } -.min-w-\[48px\] { - min-width: 48px; -} - .min-w-\[640px\] { min-width: 640px; } @@ -1742,10 +1560,6 @@ video { max-width: 10rem; } -.max-w-\[1400px\] { - max-width: 1400px; -} - .max-w-\[14rem\] { max-width: 14rem; } @@ -1758,18 +1572,6 @@ video { max-width: 16rem; } -.max-w-\[320px\] { - max-width: 320px; -} - -.max-w-\[35\%\] { - max-width: 35%; -} - -.max-w-\[360px\] { - max-width: 360px; -} - .max-w-\[480px\] { max-width: 480px; } @@ -1778,10 +1580,6 @@ video { max-width: 85%; } -.max-w-\[90\%\] { - max-width: 90%; -} - .max-w-\[92\%\] { max-width: 92%; } @@ -1853,31 +1651,16 @@ video { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } -.translate-x-1\/2 { - --tw-translate-x: 50%; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); -} - .translate-x-5 { --tw-translate-x: 1.25rem; transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } -.rotate-0 { - --tw-rotate: 0deg; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); -} - .rotate-180 { --tw-rotate: 180deg; transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } -.rotate-90 { - --tw-rotate: 90deg; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); -} - .transform { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } @@ -1902,22 +1685,10 @@ video { animation: spin 1s linear infinite; } -.cursor-col-resize { - cursor: col-resize; -} - -.cursor-default { - cursor: default; -} - .cursor-not-allowed { cursor: not-allowed; } -.cursor-ns-resize { - cursor: ns-resize; -} - .cursor-pointer { cursor: pointer; } @@ -1932,30 +1703,10 @@ video { resize: vertical; } -.resize { - resize: both; -} - -.list-inside { - list-style-position: inside; -} - -.list-disc { - list-style-type: disc; -} - .list-none { list-style-type: none; } -.auto-cols-fr { - grid-auto-columns: minmax(0, 1fr); -} - -.grid-flow-col { - grid-auto-flow: column; -} - .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } @@ -2012,22 +1763,6 @@ video { grid-template-columns: minmax(180px,0.7fr) minmax(0,1.7fr) auto; } -.grid-cols-\[minmax\(180px\2c 22\%\)_130px_1fr\] { - grid-template-columns: minmax(180px,22%) 130px 1fr; -} - -.grid-cols-\[minmax\(180px\2c 22\%\)_130px_90px_90px_1fr\] { - grid-template-columns: minmax(180px,22%) 130px 90px 90px 1fr; -} - -.grid-cols-\[minmax\(220px\2c 34\%\)_1fr_auto\] { - grid-template-columns: minmax(220px,34%) 1fr auto; -} - -.grid-cols-\[minmax\(300px\2c 34\%\)_1fr_auto\] { - grid-template-columns: minmax(300px,34%) 1fr auto; -} - .grid-cols-\[repeat\(auto-fill\2c minmax\(180px\2c 1fr\)\)\] { grid-template-columns: repeat(auto-fill,minmax(180px,1fr)); } @@ -2056,10 +1791,6 @@ video { align-items: baseline; } -.items-stretch { - align-items: stretch; -} - .justify-end { justify-content: flex-end; } @@ -2104,14 +1835,6 @@ video { gap: 1rem; } -.gap-5 { - gap: 1.25rem; -} - -.gap-6 { - gap: 1.5rem; -} - .gap-px { gap: 1px; } @@ -2131,14 +1854,6 @@ video { column-gap: 1rem; } -.gap-y-0 { - row-gap: 0px; -} - -.gap-y-0\.5 { - row-gap: 0.125rem; -} - .gap-y-1 { row-gap: 0.25rem; } @@ -2165,12 +1880,6 @@ video { margin-bottom: calc(0.25rem * var(--tw-space-y-reverse)); } -.space-y-1\.5 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(0.375rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(0.375rem * var(--tw-space-y-reverse)); -} - .space-y-2 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); @@ -2201,12 +1910,6 @@ video { margin-bottom: calc(1.25rem * var(--tw-space-y-reverse)); } -.space-y-6 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); -} - .divide-x > :not([hidden]) ~ :not([hidden]) { --tw-divide-x-reverse: 0; border-right-width: calc(1px * var(--tw-divide-x-reverse)); @@ -2320,11 +2023,6 @@ video { border-bottom-left-radius: 0.5rem; } -.rounded-l { - border-top-left-radius: 0.25rem; - border-bottom-left-radius: 0.25rem; -} - .rounded-t { border-top-left-radius: 0.25rem; border-top-right-radius: 0.25rem; @@ -2347,10 +2045,6 @@ video { border-bottom-width: 1px; } -.border-b-2 { - border-bottom-width: 2px; -} - .border-l { border-left-width: 1px; } @@ -2444,10 +2138,6 @@ video { border-color: rgb(59 130 246 / var(--tw-border-opacity, 1)); } -.border-blue-500\/50 { - border-color: rgb(59 130 246 / 0.5); -} - .border-blue-600 { --tw-border-opacity: 1; border-color: rgb(37 99 235 / var(--tw-border-opacity, 1)); @@ -2654,10 +2344,6 @@ video { border-color: rgb(71 85 105 / var(--tw-border-opacity, 1)); } -.border-slate-600\/80 { - border-color: rgb(71 85 105 / 0.8); -} - .border-slate-700 { --tw-border-opacity: 1; border-color: rgb(51 65 85 / var(--tw-border-opacity, 1)); @@ -2667,14 +2353,6 @@ video { border-color: rgb(51 65 85 / 0.3); } -.border-slate-700\/50 { - border-color: rgb(51 65 85 / 0.5); -} - -.border-slate-700\/80 { - border-color: rgb(51 65 85 / 0.8); -} - .border-slate-800 { --tw-border-opacity: 1; border-color: rgb(30 41 59 / var(--tw-border-opacity, 1)); @@ -3160,11 +2838,6 @@ video { border-left-color: rgb(238 242 255 / 0.3); } -.border-l-orange-400 { - --tw-border-opacity: 1; - border-left-color: rgb(251 146 60 / var(--tw-border-opacity, 1)); -} - .border-l-orange-500 { --tw-border-opacity: 1; border-left-color: rgb(249 115 22 / var(--tw-border-opacity, 1)); @@ -3180,11 +2853,6 @@ video { border-left-color: rgb(254 242 242 / var(--tw-border-opacity, 1)); } -.border-l-red-500 { - --tw-border-opacity: 1; - border-left-color: rgb(239 68 68 / var(--tw-border-opacity, 1)); -} - .border-l-red-600 { --tw-border-opacity: 1; border-left-color: rgb(220 38 38 / var(--tw-border-opacity, 1)); @@ -3248,20 +2916,11 @@ video { border-left-color: rgb(15 23 42 / var(--tw-border-opacity, 1)); } -.border-l-transparent { - border-left-color: transparent; -} - .border-l-violet-100 { --tw-border-opacity: 1; border-left-color: rgb(237 233 254 / var(--tw-border-opacity, 1)); } -.border-l-violet-400 { - --tw-border-opacity: 1; - border-left-color: rgb(167 139 250 / var(--tw-border-opacity, 1)); -} - .border-l-violet-700 { --tw-border-opacity: 1; border-left-color: rgb(109 40 217 / var(--tw-border-opacity, 1)); @@ -3853,10 +3512,6 @@ video { background-color: rgb(245 158 11 / var(--tw-bg-opacity, 1)); } -.bg-amber-500\/85 { - background-color: rgb(245 158 11 / 0.85); -} - .bg-amber-600 { --tw-bg-opacity: 1; background-color: rgb(217 119 6 / var(--tw-bg-opacity, 1)); @@ -3949,19 +3604,11 @@ video { background-color: rgb(59 130 246 / 0.2); } -.bg-blue-500\/85 { - background-color: rgb(59 130 246 / 0.85); -} - .bg-blue-600 { --tw-bg-opacity: 1; background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1)); } -.bg-blue-600\/25 { - background-color: rgb(37 99 235 / 0.25); -} - .bg-blue-600\/30 { background-color: rgb(37 99 235 / 0.3); } @@ -3995,10 +3642,6 @@ video { background-color: rgb(16 185 129 / var(--tw-bg-opacity, 1)); } -.bg-emerald-500\/85 { - background-color: rgb(16 185 129 / 0.85); -} - .bg-emerald-700 { --tw-bg-opacity: 1; background-color: rgb(4 120 87 / var(--tw-bg-opacity, 1)); @@ -4142,11 +3785,6 @@ video { background-color: rgb(249 115 22 / var(--tw-bg-opacity, 1)); } -.bg-pink-500 { - --tw-bg-opacity: 1; - background-color: rgb(236 72 153 / var(--tw-bg-opacity, 1)); -} - .bg-purple-100 { --tw-bg-opacity: 1; background-color: rgb(243 232 255 / var(--tw-bg-opacity, 1)); @@ -4176,11 +3814,6 @@ video { background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1)); } -.bg-red-500 { - --tw-bg-opacity: 1; - background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1)); -} - .bg-red-600 { --tw-bg-opacity: 1; background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1)); @@ -4244,27 +3877,15 @@ video { background-color: rgb(30 41 59 / var(--tw-bg-opacity, 1)); } -.bg-slate-800\/30 { - background-color: rgb(30 41 59 / 0.3); -} - .bg-slate-800\/50 { background-color: rgb(30 41 59 / 0.5); } -.bg-slate-800\/80 { - background-color: rgb(30 41 59 / 0.8); -} - .bg-slate-900 { --tw-bg-opacity: 1; background-color: rgb(15 23 42 / var(--tw-bg-opacity, 1)); } -.bg-slate-900\/40 { - background-color: rgb(15 23 42 / 0.4); -} - .bg-slate-900\/55 { background-color: rgb(15 23 42 / 0.55); } @@ -4287,23 +3908,10 @@ video { background-color: rgb(237 233 254 / var(--tw-bg-opacity, 1)); } -.bg-violet-50 { - --tw-bg-opacity: 1; - background-color: rgb(245 243 255 / var(--tw-bg-opacity, 1)); -} - .bg-violet-50\/40 { background-color: rgb(245 243 255 / 0.4); } -.bg-violet-50\/50 { - background-color: rgb(245 243 255 / 0.5); -} - -.bg-violet-50\/80 { - background-color: rgb(245 243 255 / 0.8); -} - .bg-violet-500 { --tw-bg-opacity: 1; background-color: rgb(139 92 246 / var(--tw-bg-opacity, 1)); @@ -4688,18 +4296,6 @@ video { --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); } -.from-violet-50 { - --tw-gradient-from: #f5f3ff var(--tw-gradient-from-position); - --tw-gradient-to: rgb(245 243 255 / 0) var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); -} - -.from-violet-50\/80 { - --tw-gradient-from: rgb(245 243 255 / 0.8) var(--tw-gradient-from-position); - --tw-gradient-to: rgb(245 243 255 / 0) var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); -} - .from-violet-700 { --tw-gradient-from: #6d28d9 var(--tw-gradient-from-position); --tw-gradient-to: rgb(109 40 217 / 0) var(--tw-gradient-to-position); @@ -4986,11 +4582,6 @@ video { --tw-gradient-stops: var(--tw-gradient-from), #5b21b6 var(--tw-gradient-via-position), var(--tw-gradient-to); } -.via-white { - --tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-from), #fff var(--tw-gradient-via-position), var(--tw-gradient-to); -} - .via-yellow-50 { --tw-gradient-to: rgb(254 252 232 / 0) var(--tw-gradient-to-position); --tw-gradient-stops: var(--tw-gradient-from), #fefce8 var(--tw-gradient-via-position), var(--tw-gradient-to); @@ -5258,10 +4849,6 @@ video { padding: 1rem; } -.p-5 { - padding: 1.25rem; -} - .p-6 { padding: 1.5rem; } @@ -5345,11 +4932,6 @@ video { padding-bottom: 0.375rem; } -.py-10 { - padding-top: 2.5rem; - padding-bottom: 2.5rem; -} - .py-12 { padding-top: 3rem; padding-bottom: 3rem; @@ -5380,11 +4962,6 @@ video { padding-bottom: 1rem; } -.py-5 { - padding-top: 1.25rem; - padding-bottom: 1.25rem; -} - .py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; @@ -5420,22 +4997,6 @@ video { padding-bottom: 0.5rem; } -.pb-3 { - padding-bottom: 0.75rem; -} - -.pb-4 { - padding-bottom: 1rem; -} - -.pl-1 { - padding-left: 0.25rem; -} - -.pl-2 { - padding-left: 0.5rem; -} - .pl-3 { padding-left: 0.75rem; } @@ -5444,22 +5005,10 @@ video { padding-left: 1rem; } -.pl-5 { - padding-left: 1.25rem; -} - .pl-7 { padding-left: 1.75rem; } -.pl-8 { - padding-left: 2rem; -} - -.pl-\[18px\] { - padding-left: 18px; -} - .pr-1 { padding-right: 0.25rem; } @@ -5472,22 +5021,10 @@ video { padding-right: 0.5rem; } -.pr-3 { - padding-right: 0.75rem; -} - -.pr-4 { - padding-right: 1rem; -} - .pt-0\.5 { padding-top: 0.125rem; } -.pt-1 { - padding-top: 0.25rem; -} - .pt-2 { padding-top: 0.5rem; } @@ -5528,28 +5065,15 @@ video { text-align: right; } -.align-top { - vertical-align: top; -} - .font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } -.font-sans { - font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; -} - .text-2xl { font-size: 1.5rem; line-height: 2rem; } -.text-3xl { - font-size: 1.875rem; - line-height: 2.25rem; -} - .text-\[10px\] { font-size: 10px; } @@ -5558,14 +5082,6 @@ video { font-size: 11px; } -.text-\[7px\] { - font-size: 7px; -} - -.text-\[8px\] { - font-size: 8px; -} - .text-\[9px\] { font-size: 9px; } @@ -5684,11 +5200,6 @@ video { color: rgb(245 158 11 / var(--tw-text-opacity, 1)); } -.text-amber-600 { - --tw-text-opacity: 1; - color: rgb(217 119 6 / var(--tw-text-opacity, 1)); -} - .text-amber-700 { --tw-text-opacity: 1; color: rgb(180 83 9 / var(--tw-text-opacity, 1)); @@ -5724,10 +5235,6 @@ video { color: rgb(147 197 253 / var(--tw-text-opacity, 1)); } -.text-blue-300\/80 { - color: rgb(147 197 253 / 0.8); -} - .text-blue-400 { --tw-text-opacity: 1; color: rgb(96 165 250 / var(--tw-text-opacity, 1)); @@ -5757,10 +5264,6 @@ video { color: rgb(29 78 216 / var(--tw-text-opacity, 1)); } -.text-blue-700\/90 { - color: rgb(29 78 216 / 0.9); -} - .text-blue-800 { --tw-text-opacity: 1; color: rgb(30 64 175 / var(--tw-text-opacity, 1)); @@ -5785,15 +5288,6 @@ video { color: rgb(167 243 208 / var(--tw-text-opacity, 1)); } -.text-emerald-300 { - --tw-text-opacity: 1; - color: rgb(110 231 183 / var(--tw-text-opacity, 1)); -} - -.text-emerald-400\/90 { - color: rgb(52 211 153 / 0.9); -} - .text-emerald-50 { --tw-text-opacity: 1; color: rgb(236 253 245 / var(--tw-text-opacity, 1)); @@ -5834,11 +5328,6 @@ video { color: rgb(229 231 235 / var(--tw-text-opacity, 1)); } -.text-gray-300 { - --tw-text-opacity: 1; - color: rgb(209 213 219 / var(--tw-text-opacity, 1)); -} - .text-gray-400 { --tw-text-opacity: 1; color: rgb(156 163 175 / var(--tw-text-opacity, 1)); @@ -5918,11 +5407,6 @@ video { color: rgb(234 88 12 / var(--tw-text-opacity, 1)); } -.text-orange-700 { - --tw-text-opacity: 1; - color: rgb(194 65 12 / var(--tw-text-opacity, 1)); -} - .text-orange-800 { --tw-text-opacity: 1; color: rgb(154 52 18 / var(--tw-text-opacity, 1)); @@ -6056,10 +5540,6 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity, 1)); } -.text-white\/70 { - color: rgb(255 255 255 / 0.7); -} - .text-white\/90 { color: rgb(255 255 255 / 0.9); } @@ -6079,26 +5559,10 @@ video { color: rgb(133 77 14 / var(--tw-text-opacity, 1)); } -.underline { - text-decoration-line: underline; -} - -.underline-offset-2 { - text-underline-offset: 2px; -} - .accent-blue-500 { accent-color: #3b82f6; } -.accent-blue-600 { - accent-color: #2563eb; -} - -.opacity-0 { - opacity: 0; -} - .opacity-60 { opacity: 0.6; } @@ -6230,20 +5694,11 @@ video { --tw-ring-color: rgb(191 219 254 / var(--tw-ring-opacity, 1)); } -.ring-blue-300 { - --tw-ring-opacity: 1; - --tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity, 1)); -} - .ring-blue-400 { --tw-ring-opacity: 1; --tw-ring-color: rgb(96 165 250 / var(--tw-ring-opacity, 1)); } -.ring-blue-400\/50 { - --tw-ring-color: rgb(96 165 250 / 0.5); -} - .ring-blue-50 { --tw-ring-opacity: 1; --tw-ring-color: rgb(239 246 255 / var(--tw-ring-opacity, 1)); @@ -6382,11 +5837,6 @@ video { --tw-ring-color: rgb(254 242 242 / var(--tw-ring-opacity, 1)); } -.ring-red-500 { - --tw-ring-opacity: 1; - --tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity, 1)); -} - .ring-red-600 { --tw-ring-opacity: 1; --tw-ring-color: rgb(220 38 38 / var(--tw-ring-opacity, 1)); @@ -6510,12 +5960,6 @@ video { filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); } -.backdrop-blur { - --tw-backdrop-blur: blur(8px); - -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); - backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); -} - .backdrop-blur-md { --tw-backdrop-blur: blur(12px); -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); @@ -6546,12 +5990,6 @@ video { transition-duration: 150ms; } -.transition-opacity { - transition-property: opacity; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 150ms; -} - .transition-shadow { transition-property: box-shadow; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); @@ -6568,14 +6006,6 @@ video { transition-duration: 150ms; } -.duration-200 { - transition-duration: 200ms; -} - -.duration-300 { - transition-duration: 300ms; -} - .duration-500 { transition-duration: 500ms; } @@ -6668,21 +6098,6 @@ video { padding-right: 0px; } -.focus-within\:border-blue-500:focus-within { - --tw-border-opacity: 1; - border-color: rgb(59 130 246 / var(--tw-border-opacity, 1)); -} - -.focus-within\:ring-2:focus-within { - --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); - --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); - box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); -} - -.focus-within\:ring-blue-500\/30:focus-within { - --tw-ring-color: rgb(59 130 246 / 0.3); -} - .hover\:border-blue-200:hover { --tw-border-opacity: 1; border-color: rgb(191 219 254 / var(--tw-border-opacity, 1)); @@ -6698,19 +6113,6 @@ video { border-color: rgb(96 165 250 / var(--tw-border-opacity, 1)); } -.hover\:border-blue-700\/40:hover { - border-color: rgb(29 78 216 / 0.4); -} - -.hover\:border-emerald-700\/40:hover { - border-color: rgb(4 120 87 / 0.4); -} - -.hover\:border-slate-500:hover { - --tw-border-opacity: 1; - border-color: rgb(100 116 139 / var(--tw-border-opacity, 1)); -} - .hover\:border-slate-600:hover { --tw-border-opacity: 1; border-color: rgb(71 85 105 / var(--tw-border-opacity, 1)); @@ -6736,11 +6138,6 @@ video { background-color: rgb(245 158 11 / var(--tw-bg-opacity, 1)); } -.hover\:bg-amber-600:hover { - --tw-bg-opacity: 1; - background-color: rgb(217 119 6 / var(--tw-bg-opacity, 1)); -} - .hover\:bg-amber-800:hover { --tw-bg-opacity: 1; background-color: rgb(146 64 14 / var(--tw-bg-opacity, 1)); @@ -6855,10 +6252,6 @@ video { background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1)); } -.hover\:bg-gray-50\/50:hover { - background-color: rgb(249 250 251 / 0.5); -} - .hover\:bg-gray-50\/60:hover { background-color: rgb(249 250 251 / 0.6); } @@ -6871,10 +6264,6 @@ video { background-color: rgb(249 250 251 / 0.8); } -.hover\:bg-gray-50\/90:hover { - background-color: rgb(249 250 251 / 0.9); -} - .hover\:bg-gray-500:hover { --tw-bg-opacity: 1; background-color: rgb(107 114 128 / var(--tw-bg-opacity, 1)); @@ -7007,10 +6396,6 @@ video { background-color: rgb(30 41 59 / 0.5); } -.hover\:bg-slate-800\/60:hover { - background-color: rgb(30 41 59 / 0.6); -} - .hover\:bg-slate-900:hover { --tw-bg-opacity: 1; background-color: rgb(15 23 42 / var(--tw-bg-opacity, 1)); @@ -7026,11 +6411,6 @@ video { background-color: rgb(245 243 255 / var(--tw-bg-opacity, 1)); } -.hover\:bg-violet-600:hover { - --tw-bg-opacity: 1; - background-color: rgb(124 58 237 / var(--tw-bg-opacity, 1)); -} - .hover\:bg-violet-700:hover { --tw-bg-opacity: 1; background-color: rgb(109 40 217 / var(--tw-bg-opacity, 1)); @@ -7366,11 +6746,6 @@ video { box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); } -.hover\:ring-blue-300:hover { - --tw-ring-opacity: 1; - --tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity, 1)); -} - .hover\:ring-blue-400\/60:hover { --tw-ring-color: rgb(96 165 250 / 0.6); } @@ -7379,10 +6754,6 @@ video { --tw-ring-color: rgb(52 211 153 / 0.7); } -.hover\:ring-offset-1:hover { - --tw-ring-offset-width: 1px; -} - .focus\:not-sr-only:focus { position: static; width: auto; @@ -7704,10 +7075,6 @@ video { --tw-ring-offset-width: 1px; } -.focus\:ring-offset-2:focus { - --tw-ring-offset-width: 2px; -} - .focus\:ring-offset-amber-100:focus { --tw-ring-offset-color: #fef3c7; } @@ -7968,19 +7335,10 @@ video { --tw-ring-offset-width: 2px; } -.active\:bg-gray-200:active { - --tw-bg-opacity: 1; - background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1)); -} - .disabled\:pointer-events-none:disabled { pointer-events: none; } -.disabled\:cursor-default:disabled { - cursor: default; -} - .disabled\:cursor-not-allowed:disabled { cursor: not-allowed; } @@ -7998,20 +7356,6 @@ video { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } -.group:hover .group-hover\:bg-gray-400 { - --tw-bg-opacity: 1; - background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1)); -} - -.group:hover .group-hover\:opacity-100 { - opacity: 1; -} - -.group:active .group-active\:bg-gray-500 { - --tw-bg-opacity: 1; - background-color: rgb(107 114 128 / var(--tw-bg-opacity, 1)); -} - @media (prefers-reduced-motion: reduce) { .motion-reduce\:animate-none { animation: none; @@ -8019,30 +7363,14 @@ video { } @media (min-width: 640px) { - .sm\:col-span-2 { - grid-column: span 2 / span 2; - } - .sm\:ml-auto { margin-left: auto; } - .sm\:inline { - display: inline; - } - .sm\:flex { display: flex; } - .sm\:max-w-5xl { - max-width: 64rem; - } - - .sm\:max-w-md { - max-width: 28rem; - } - .sm\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -8055,10 +7383,6 @@ video { grid-template-columns: repeat(4, minmax(0, 1fr)); } - .sm\:items-center { - align-items: center; - } - .sm\:divide-x > :not([hidden]) ~ :not([hidden]) { --tw-divide-x-reverse: 0; border-right-width: calc(1px * var(--tw-divide-x-reverse)); @@ -8071,14 +7395,6 @@ video { border-bottom-width: calc(0px * var(--tw-divide-y-reverse)); } - .sm\:rounded-xl { - border-radius: 0.75rem; - } - - .sm\:p-4 { - padding: 1rem; - } - .sm\:p-6 { padding: 1.5rem; } @@ -8102,45 +7418,13 @@ video { .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } - - .md\:grid-cols-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } } @media (min-width: 1024px) { - .lg\:col-span-4 { - grid-column: span 4 / span 4; - } - - .lg\:col-span-5 { - grid-column: span 5 / span 5; - } - - .lg\:col-span-7 { - grid-column: span 7 / span 7; - } - - .lg\:col-span-8 { - grid-column: span 8 / span 8; - } - - .lg\:grid-cols-12 { - grid-template-columns: repeat(12, minmax(0, 1fr)); - } - - .lg\:grid-cols-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } - .lg\:grid-cols-5 { - grid-template-columns: repeat(5, minmax(0, 1fr)); - } - .lg\:grid-cols-\[minmax\(9rem\2c 1fr\)_minmax\(18rem\2c 2fr\)\] { grid-template-columns: minmax(9rem,1fr) minmax(18rem,2fr); } @@ -8155,14 +7439,6 @@ video { } @media (min-width: 1280px) { - .xl\:col-span-2 { - grid-column: span 2 / span 2; - } - - .xl\:block { - display: block; - } - .xl\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -8195,9 +7471,3 @@ video { border-top-width: 0px; } } - -@media (min-width: 1536px) { - .\32xl\:grid-cols-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } -} diff --git a/web/src/agent/cluster.rs b/web/src/agent/cluster.rs index ca0fe88f..86db42e0 100644 --- a/web/src/agent/cluster.rs +++ b/web/src/agent/cluster.rs @@ -13,10 +13,6 @@ pub struct ClusterSnapshot { } impl ClusterSnapshot { - pub fn has_peers(&self) -> bool { - self.peer_count > 0 - } - pub fn is_distributed(&self) -> bool { self.node_count > 1 || self.peer_count > 0 } diff --git a/web/src/agent/mod.rs b/web/src/agent/mod.rs index c5be4801..46df2cb1 100644 --- a/web/src/agent/mod.rs +++ b/web/src/agent/mod.rs @@ -3,7 +3,6 @@ mod cluster; mod interpret; mod llm; -pub mod page_tools; mod routing; mod runner; mod skill; @@ -13,7 +12,6 @@ mod source_bridge; pub use cluster::fetch_cluster_snapshot; pub use interpret::{build_skill_summary, evaluate_rules_for_skill, format_findings}; pub use llm::{outcomes_to_evidence, select_skill, summarize_run}; -pub use page_tools::refresh_page_snapshot_for_route; pub use routing::{catalog_skills, routing_context_for_llm}; pub use runner::{run_skill, StepOutcome}; pub use skill::{ diff --git a/web/src/agent/page_tools.rs b/web/src/agent/page_tools.rs deleted file mode 100644 index 9297c773..00000000 --- a/web/src/agent/page_tools.rs +++ /dev/null @@ -1,330 +0,0 @@ -//! Page-aware tools: describe routes and fetch live snapshots for the Agent / LLM. - -use dioxus::prelude::ReadableExt; -use probing_proto::prelude::{DataFrame, Ele}; - -use crate::api::ApiClient; -use crate::app::Route; -use crate::components::flamegraph::FlamegraphPayload; -use crate::next::capabilities::capability_available; -use crate::state::profiling::{ - normalize_profiling_view, PROFILING_PPROF_FREQ, PROFILING_TORCH_ENABLED, -}; -use crate::state::ui_tasks::{begin_snapshot_task, end_snapshot_task}; -use crate::utils::error::Result; - -pub use crate::agent::routing::describe_route; - -fn ele_str(ele: &Ele) -> String { - match ele { - Ele::Nil => "—".into(), - Ele::BOOL(x) => x.to_string(), - Ele::I32(x) => x.to_string(), - Ele::I64(x) => x.to_string(), - Ele::F32(x) => format!("{x:.4}"), - Ele::F64(x) => format!("{x:.4}"), - Ele::Text(x) => x.clone(), - Ele::Url(x) => x.clone(), - Ele::DataTime(x) => x.to_string(), - } -} - -pub fn dataframe_preview(df: &DataFrame, max_rows: usize) -> String { - let nrows = df.cols.iter().map(|c| c.len()).max().unwrap_or(0); - if nrows == 0 || df.names.is_empty() { - return "(empty)".into(); - } - let take = nrows.min(max_rows); - let mut lines = vec![df.names.join("\t")]; - for row in 0..take { - let cells: Vec = df.cols.iter().map(|col| ele_str(&col.get(row))).collect(); - lines.push(cells.join("\t")); - } - if nrows > take { - lines.push(format!("… +{} rows", nrows - take)); - } - lines.join("\n") -} - -async fn query_preview(client: &ApiClient, sql: &str, max_rows: usize) -> Option { - match client.execute_query(sql).await { - Ok(df) => Some(dataframe_preview(&df, max_rows)), - Err(_) => None, - } -} - -/// Fetch live page snapshot via SQL/API (Agent tool: `get_page_snapshot`). -pub async fn fetch_page_snapshot(route: &Route) -> Result { - let client = ApiClient::new(); - let mut parts: Vec = Vec::new(); - - let cluster = crate::agent::fetch_cluster_snapshot().await; - parts.push(format!("[cluster]\n{}", cluster.nodes_summary)); - - match route { - Route::DashboardPage {} => { - if let Some(p) = query_preview( - &client, - "SELECT ts, cpu_total_pct, rss_kb, thread_count FROM cpu.utilization WHERE scope='process' ORDER BY ts DESC LIMIT 3", - 3, - ) - .await - { - parts.push(format!("[cpu.utilization]\n{p}")); - } - if let Some(p) = query_preview( - &client, - "SELECT ts, name, mem_used_pct, gpu_util_pct FROM gpu.utilization ORDER BY ts DESC LIMIT 3", - 3, - ) - .await - { - parts.push(format!("[gpu.utilization]\n{p}")); - } - if capability_available("python", "torch_trace", &["local_step"]).await { - if let Some(p) = query_preview( - &client, - "SELECT max(local_step) AS latest_step FROM python.torch_trace", - 1, - ) - .await - { - parts.push(format!("[torch_trace]\n{p}")); - } - } - } - Route::ClusterPage {} => { - if let Ok(nodes) = client.get_nodes().await { - parts.push(format!("[cluster.nodes] {} registered", nodes.len())); - } - if cluster.has_peers() { - if let Ok(resp) = client - .cluster_query( - "SELECT _rank, op, avg(duration_ms) AS avg_ms, count(*) AS n \ - FROM global.python.comm_collective \ - GROUP BY _rank, op ORDER BY avg_ms DESC LIMIT 8", - true, - ) - .await - { - let preview = dataframe_preview(&resp.dataframe, 8); - parts.push(format!( - "[global.python.comm_collective · {} nodes]\n{preview}", - resp.meta.nodes_queried - )); - } - } - } - Route::StackPage {} => { - if let Ok(frames) = client.get_callstack_with_mode(None, "mixed").await { - let preview: String = frames - .iter() - .take(12) - .map(|f| format!("{f}")) - .collect::>() - .join("\n"); - parts.push(format!("[callstack top 12]\n{preview}")); - } - } - Route::StackWithTidPage { tid } => { - if let Ok(frames) = client - .get_callstack_with_mode(Some(tid.clone()), "mixed") - .await - { - let preview: String = frames - .iter() - .take(12) - .map(|f| format!("{f}")) - .collect::>() - .join("\n"); - parts.push(format!("[callstack tid={tid} top 12]\n{preview}")); - } - } - Route::ProfilingViewPage { view } => { - let v = normalize_profiling_view(view); - parts.push(format!( - "UI state: pprof.sample_freq={} torch.profiling={}", - *PROFILING_PPROF_FREQ.read(), - *PROFILING_TORCH_ENABLED.read() - )); - if let Ok(cfg) = client.get_profiler_config().await { - for (k, val) in cfg { - parts.push(format!("{k}={val}")); - } - } - match v { - "torch" if capability_available("python", "torch_trace", &["local_step"]).await => { - if let Some(p) = query_preview( - &client, - "SELECT max(local_step) AS latest_step, count(*) AS rows FROM python.torch_trace", - 1, - ) - .await - { - parts.push(format!("[torch_trace]\n{p}")); - } - } - "pprof" => { - let freq = *PROFILING_PPROF_FREQ.read(); - parts.push(format!("[pprof] probing.pprof.sample_freq={freq}")); - if freq > 0 { - match client.get_flamegraph_json("pprof").await { - Ok(json) => { - if let Ok(payload) = - serde_json::from_str::(&json) - { - parts.push(format!( - "[pprof flamegraph] total_samples={} dropped={}", - payload.total, payload.dropped - )); - } else { - parts.push( - "[pprof] sampling enabled; flamegraph JSON not ready yet" - .into(), - ); - } - } - Err(_) => parts.push( - "[pprof] sampling enabled but no CPU stacks collected yet" - .into(), - ), - } - } else { - parts.push( - "[pprof] CPU sampling off — enable sample_freq in Profiling sidebar" - .into(), - ); - } - } - _ => {} - } - } - Route::TrainingPage {} => { - let collective_available = capability_available( - "python", - "comm_collective", - &["rank", "op", "duration_ms"], - ) - .await; - if cluster.has_peers() { - if let Ok(resp) = client - .cluster_query( - "SELECT _rank, op, avg(duration_ms) AS avg_ms, count(*) AS n \ - FROM global.python.comm_collective \ - GROUP BY _rank, op ORDER BY avg_ms DESC LIMIT 8", - true, - ) - .await - { - let preview = dataframe_preview(&resp.dataframe, 8); - parts.push(format!( - "[global comm_collective · {} nodes]\n{preview}", - resp.meta.nodes_queried - )); - } - } else if collective_available { - if let Some(p) = query_preview( - &client, - "SELECT rank, op, avg(duration_ms) AS avg_ms, count(*) AS n FROM python.comm_collective GROUP BY rank, op ORDER BY avg_ms DESC LIMIT 8", - 8, - ) - .await - { - parts.push(format!("[comm_collective by rank]\n{p}")); - } - } - if capability_available("python", "torch_trace", &["local_step"]).await { - if let Some(p) = query_preview( - &client, - "SELECT max(local_step) AS latest_step FROM python.torch_trace", - 1, - ) - .await - { - parts.push(format!("[torch_trace]\n{p}")); - } - } - } - Route::SpansPage {} | Route::TracesRedirect {} => { - if capability_available("python", "trace_event", &["record_type"]).await { - if let Some(p) = query_preview( - &client, - "SELECT record_type, count(*) AS n FROM python.trace_event GROUP BY record_type", - 8, - ) - .await - { - parts.push(format!("[trace_event counts]\n{p}")); - } - } - } - Route::PulsingPage {} => { - if let Some(p) = query_preview(&client, "SELECT count(*) AS actors FROM pulsing.actors", 1).await { - parts.push(format!("[pulsing.actors]\n{p}")); - } - } - Route::AnalyticsPage {} => { - if let Some(p) = query_preview( - &client, - "SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema NOT IN ('information_schema') ORDER BY 1,2 LIMIT 12", - 12, - ) - .await - { - parts.push(format!("[tables]\n{p}")); - } - } - _ => {} - } - - if parts.is_empty() { - Ok("(no live snapshot for this page)".into()) - } else { - Ok(parts.join("\n\n")) - } -} - -pub async fn refresh_page_snapshot_for_route(route: Route) { - refresh_page_snapshot(route, true).await; -} - -/// Refresh without blanking the UI when a snapshot is already shown (e.g. manual Refresh). -pub async fn refresh_page_snapshot_quiet(route: Route) { - refresh_page_snapshot(route, false).await; -} - -async fn refresh_page_snapshot(route: Route, show_loading: bool) { - let had_snapshot = !crate::state::page_context::PAGE_CONTEXT - .read() - .snapshot - .is_empty(); - if show_loading && !had_snapshot { - crate::state::page_context::set_page_snapshot_loading(true); - } - - let detail = describe_route(&route).title; - let task = begin_snapshot_task("Page snapshot", Some(detail)); - let task_id = task.id(); - - let result = fetch_page_snapshot(&route).await; - - if task.is_cancelled() { - task.cancel(); - end_snapshot_task(task_id); - return; - } - - match result { - Ok(s) => { - task.finish(); - end_snapshot_task(task_id); - crate::state::page_context::set_page_snapshot(s); - } - Err(e) => { - let msg = e.display_message(); - task.fail(&msg); - end_snapshot_task(task_id); - crate::state::page_context::set_page_snapshot(format!("(snapshot error: {msg})")); - } - } -} diff --git a/web/src/agent/routing.rs b/web/src/agent/routing.rs index 88098924..d876b57b 100644 --- a/web/src/agent/routing.rs +++ b/web/src/agent/routing.rs @@ -1,17 +1,6 @@ //! Catalog, intent, and page routing — loaded from probing server at runtime. -use crate::agent::skill::{catalog_entries, intent_catalog, page_catalog, CatalogEntry}; -use crate::app::Route; -use crate::state::profiling::normalize_profiling_view; - -#[derive(Debug, Clone)] -pub struct PageDescriptor { - pub page_id: String, - pub title: String, - pub path: String, - pub description: String, - pub suggested_skills: Vec, -} +use crate::agent::skill::{catalog_entries, intent_catalog, CatalogEntry}; pub fn catalog_skills() -> Vec { catalog_entries() @@ -27,65 +16,6 @@ fn catalog_entry(id: &str) -> Option { catalog_skills().into_iter().find(|e| e.id == id) } -pub fn page_id_for_route(route: &Route) -> String { - match route { - Route::DashboardPage {} => "dashboard".into(), - Route::AgentPage {} => "agent".into(), - Route::ClusterPage {} => "cluster".into(), - Route::StackPage {} => "stacks".into(), - Route::StackDistributedFullPage {} => "stacks/distributed".into(), - Route::StackDistributedPyPage {} => "stacks/distributed/py".into(), - Route::StackWithTidPage { tid } => format!("stacks/{tid}"), - Route::ProfilingViewPage { view } => { - format!("profiling/{}", normalize_profiling_view(view)) - } - Route::ProfilingRedirect {} | Route::ChromeTracingRedirect {} => "profiling".into(), - Route::AnalyticsPage {} => "analytics".into(), - Route::PythonPage {} => "python".into(), - Route::SpansPage {} | Route::TracesRedirect {} => "spans".into(), - Route::PulsingPage {} => "pulsing".into(), - Route::TrainingPage {} => "training".into(), - Route::RolloutPage {} => "rl/rollout".into(), - Route::TrainPage {} => "rl/train".into(), - Route::RlSpansPage {} => "rl/spans".into(), - Route::ProcessTimelinePage {} => "rl/process-timeline".into(), - Route::PerfettoPage {} => "rl/perfetto".into(), - Route::InferencePage {} => "rl/inference".into(), - } -} - -pub fn describe_route(route: &Route) -> PageDescriptor { - let page_id = page_id_for_route(route); - let pages = page_catalog(); - if let Some(entry) = pages.get(&page_id) { - return PageDescriptor { - page_id: page_id.clone(), - title: entry.title.clone(), - path: entry.path.clone(), - description: entry.description.clone(), - suggested_skills: entry.skills.clone(), - }; - } - if page_id.starts_with("stacks/") { - if let Some(entry) = pages.get("stacks") { - return PageDescriptor { - page_id: page_id.clone(), - title: format!("{} · {}", entry.title, page_id), - path: format!("/{}", page_id), - description: entry.description.clone(), - suggested_skills: entry.skills.clone(), - }; - } - } - PageDescriptor { - page_id: page_id.clone(), - title: page_id.clone(), - path: "/".into(), - description: String::new(), - suggested_skills: vec!["health_overview".into()], - } -} - pub fn routing_context_for_llm() -> String { let mut lines = vec!["Skill catalog (by priority):".to_string()]; for id in catalog_skill_ids() { diff --git a/web/src/agent/skill.rs b/web/src/agent/skill.rs index 1d0024e0..abe5b3a4 100644 --- a/web/src/agent/skill.rs +++ b/web/src/agent/skill.rs @@ -57,6 +57,7 @@ pub struct PageCatalogFile { } #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] // Deserialized from routing API; not read after Classic page catalog removal. pub struct PageEntry { pub title: String, pub path: String, @@ -118,10 +119,6 @@ pub fn intent_catalog() -> HashMap { .unwrap_or_default() } -pub fn page_catalog() -> HashMap { - store().read().map(|s| s.pages.clone()).unwrap_or_default() -} - pub fn list_skill_ids() -> Vec { store() .read() diff --git a/web/src/api/analytics.rs b/web/src/api/analytics.rs index 438f0ddb..81b8e7e0 100644 --- a/web/src/api/analytics.rs +++ b/web/src/api/analytics.rs @@ -41,20 +41,4 @@ impl ApiClient { } } } - - /// Preview query (with fallback): prioritize getting latest 10 rows by first column descending, fallback to limit 10 on failure - pub async fn execute_preview_last10(&self, table: &str) -> Result { - let try_sqls = [ - format!("select * from {} order by 1 desc limit 10", table), - format!("select * from {} limit 10", table), - ]; - let mut last_err: Option = None; - for sql in try_sqls { - match self.execute_query(&sql).await { - Ok(df) => return Ok(df), - Err(e) => last_err = Some(e), - } - } - Err(last_err.unwrap_or_else(|| AppError::Api("Preview query failed".to_string()))) - } } diff --git a/web/src/api/cpu.rs b/web/src/api/cpu.rs index 4443381b..5be95eaf 100644 --- a/web/src/api/cpu.rs +++ b/web/src/api/cpu.rs @@ -274,20 +274,6 @@ fn parse_cpu_top_threads(df: &DataFrame, limit: usize) -> Result String { - if kb >= 1024 * 1024 { - format!("{:.1} GB", kb as f64 / (1024.0 * 1024.0)) - } else if kb >= 1024 { - format!("{:.1} MB", kb as f64 / 1024.0) - } else { - format!("{kb} KB") - } -} - -pub fn format_pct(v: f32) -> String { - format!("{v:.1}%") -} - pub fn format_cpu_ms(ns: i64) -> String { format!("{:.1} ms", ns as f64 / 1_000_000.0) } diff --git a/web/src/api/gpu.rs b/web/src/api/gpu.rs index 02b5c003..415502d6 100644 --- a/web/src/api/gpu.rs +++ b/web/src/api/gpu.rs @@ -4,18 +4,6 @@ use super::ApiClient; use crate::utils::error::{AppError, Result}; use probing_proto::prelude::{DataFrame, Ele}; -/// Static GPU device from `gpu.devices`. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct GpuDeviceRow { - pub device_id: i32, - pub backend: String, - pub name: String, - pub memory_model: String, - pub chip: Option, - pub compute_capability: Option, - pub total_mem_bytes: i64, -} - /// Latest per-device sample from `gpu.utilization` (memory + compute util merged). #[derive(Clone, Debug, Default, PartialEq)] pub struct GpuSnapshot { @@ -42,16 +30,6 @@ pub struct GpuHistorySample { } impl ApiClient { - pub async fn fetch_gpu_devices(&self) -> Result> { - let df = self - .execute_query( - "SELECT device_id, backend, name, memory_model, chip, compute_capability, total_mem_bytes \ - FROM gpu.devices ORDER BY device_id", - ) - .await?; - parse_gpu_devices(&df) - } - /// Latest utilization row per `device_id` (supports multi-GPU / 8× nodes). pub async fn fetch_gpu_latest(&self) -> Result> { let df = self @@ -182,38 +160,6 @@ fn non_empty(value: String) -> Option { (!value.is_empty()).then_some(value) } -fn parse_gpu_devices(df: &DataFrame) -> Result> { - const COLUMNS: &[&str] = &[ - "device_id", - "backend", - "name", - "memory_model", - "chip", - "compute_capability", - "total_mem_bytes", - ]; - let (rows, indexes) = required_columns(df, COLUMNS)?; - let mut devices = Vec::with_capacity(rows); - for row in 0..rows { - let text = |position| { - ele_text( - cell(df, row, indexes[position], COLUMNS[position])?, - COLUMNS[position], - ) - }; - devices.push(GpuDeviceRow { - device_id: ele_i32(cell(df, row, indexes[0], COLUMNS[0])?, COLUMNS[0])?, - backend: text(1)?, - name: text(2)?, - memory_model: text(3)?, - chip: non_empty(text(4)?.trim().to_string()), - compute_capability: non_empty(text(5)?.trim().to_string()), - total_mem_bytes: ele_i64(cell(df, row, indexes[6], COLUMNS[6])?, COLUMNS[6])?, - }); - } - Ok(devices) -} - fn parse_gpu_snapshots(df: &DataFrame) -> Result> { const COLUMNS: &[&str] = &[ "ts", @@ -282,22 +228,7 @@ fn parse_gpu_history( Ok(map) } -pub fn format_bytes(bytes: i64) -> String { - const GB: f64 = 1024.0 * 1024.0 * 1024.0; - const MB: f64 = 1024.0 * 1024.0; - const KB: f64 = 1024.0; - let b = bytes as f64; - if b >= GB { - format!("{:.1} GB", b / GB) - } else if b >= MB { - format!("{:.1} MB", b / MB) - } else if b >= KB { - format!("{:.1} KB", b / KB) - } else { - format!("{bytes} B") - } -} - +#[cfg(test)] pub fn gpu_device_label(device_id: i32, name: &str) -> String { let short = name.split_whitespace().next().unwrap_or(name); if name.len() > 24 { @@ -307,11 +238,6 @@ pub fn gpu_device_label(device_id: i32, name: &str) -> String { } } -pub fn format_opt_pct(v: Option) -> String { - v.map(|p| format!("{p:.1}%")) - .unwrap_or_else(|| "—".to_string()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/web/src/api/pytorch.rs b/web/src/api/pytorch.rs index 596fc5dc..99f2a366 100644 --- a/web/src/api/pytorch.rs +++ b/web/src/api/pytorch.rs @@ -89,7 +89,7 @@ impl ApiClient { /// Start PyTorch profiler, specify the number of steps to profile pub async fn start_pytorch_profile(&self, steps: i32) -> Result { - let path = format!("/apis/pythonext/pytorch/profile?steps={}", steps); + let path = format!("/apis/pythonext/pytorch/profile/start?steps={}", steps); let response = self.get_request(&path).await?; let result: ProfileResponse = Self::parse_json(&response)?; Ok(result) diff --git a/web/src/api/stack.rs b/web/src/api/stack.rs index 50b26ee0..7d320541 100644 --- a/web/src/api/stack.rs +++ b/web/src/api/stack.rs @@ -35,7 +35,7 @@ impl ApiClient { _ => "mixed", }; let path = format!( - "/apis/training/distributed_stack_flamegraph/json?cluster={cluster}&mode={mode}" + "/apis/pprofextension/flamegraph/distributed/json?cluster={cluster}&mode={mode}" ); let body = self .get_request_accepting_with_timeout( diff --git a/web/src/api/trace.rs b/web/src/api/trace.rs index 1da14e4d..6788e65a 100644 --- a/web/src/api/trace.rs +++ b/web/src/api/trace.rs @@ -1,6 +1,5 @@ use super::ApiClient; use crate::utils::error::Result; -use probing_proto::prelude::DataFrame; use serde::{Deserialize, Serialize}; /// Trace API response structure @@ -116,55 +115,4 @@ impl ApiClient { } Self::parse_json(&response) } - - /// Get variable change records (via SQL query) - /// Returns DataFrame directly, uses SQL AS to control column name display - pub async fn get_variable_records( - &self, - function: Option<&str>, - limit: Option, - ) -> Result { - // Build SQL query with column renaming via AS (SQL controls column names) - let limit_clause = limit.map(|l| format!(" LIMIT {}", l)).unwrap_or_default(); - let where_clause = if let Some(func) = function { - // Escape single quotes in function name - let escaped_func = func.replace("'", "''"); - format!(" WHERE function_name = '{}'", escaped_func) - } else { - String::new() - }; - - // Use snake_case column names (DataFusion lowercases unquoted aliases). - let queries = [ - format!( - "SELECT function_name, filename, lineno, variable_name, value, value_type, timestamp FROM python.trace_variables{} ORDER BY timestamp DESC{}", - where_clause, limit_clause - ), - format!( - "SELECT function_name, filename, lineno, variable_name, value, value_type, timestamp FROM trace_variables{} ORDER BY timestamp DESC{}", - where_clause, limit_clause - ), - ]; - - // Try each query until one succeeds - let mut last_err: Option = None; - for query in queries.iter() { - match self.execute_query(query).await { - Ok(df) => { - return Ok(df); - } - Err(e) => { - last_err = Some(e); - continue; - } - } - } - - // If all queries failed, return error - Err(last_err.unwrap_or_else(|| { - crate::utils::error::AppError::Api( - "Failed to query python.trace_variables table".to_string(), - ) - })) - } } diff --git a/web/src/app.rs b/web/src/app.rs deleted file mode 100644 index a9a2a701..00000000 --- a/web/src/app.rs +++ /dev/null @@ -1,295 +0,0 @@ -//! App entry and routing. -//! -//! Each route variant maps to a page component wrapped in [AppLayout](crate::components::layout::AppLayout). -//! See `DESIGN.md` in this directory for structure and conventions. - -use dioxus::prelude::*; -use dioxus_router::{Routable, Router}; - -use crate::components::app_overlays::AppOverlays; - -use crate::components::common::LoadingState; -use crate::components::layout::AppLayout; -use crate::pages::{ - agent::Agent, - analytics::Analytics, - cluster::Cluster, - dashboard::Dashboard, - profiling::Profiling, - pulsing::Pulsing, - python::Python, - rl::{Inference, RlObservability, RlViewMode}, - stack::{Stack, StackDistributed}, - traces::Traces, - training::Training, -}; -use crate::state::profiling::normalize_profiling_view; - -/// All routes. Each is rendered inside AppLayout by the corresponding page component below. -#[derive(Routable, Clone, PartialEq)] -#[rustfmt::skip] -pub enum Route { - #[route("/")] - DashboardPage {}, - #[route("/rl")] - #[route("/rl/rollout")] - RolloutPage {}, - #[route("/rl/train")] - TrainPage {}, - #[route("/rl/spans")] - RlSpansPage {}, - #[route("/rl/process-timeline")] - ProcessTimelinePage {}, - #[route("/rl/perfetto")] - PerfettoPage {}, - #[route("/rl/inference")] - InferencePage {}, - #[route("/agent")] - AgentPage {}, - #[route("/cluster")] - ClusterPage {}, - #[route("/stacks")] - StackPage {}, - #[route("/stacks/distributed")] - StackDistributedFullPage {}, - #[route("/stacks/distributed/py")] - StackDistributedPyPage {}, - #[route("/stacks/:tid")] - StackWithTidPage { tid: String }, - #[route("/profiling")] - ProfilingRedirect {}, - #[route("/profiling/:view")] - ProfilingViewPage { view: String }, - #[route("/analytics")] - AnalyticsPage {}, - #[route("/python")] - PythonPage {}, - #[route("/traces")] - TracesRedirect {}, - #[route("/spans")] - SpansPage {}, - #[route("/chrome-tracing")] - ChromeTracingRedirect {}, - #[route("/pulsing")] - PulsingPage {}, - #[route("/training")] - TrainingPage {}, -} - -// --- Page route components: each wraps a page in AppLayout --- - -#[component] -pub fn RolloutPage() -> Element { - rsx! { AppLayout { RlObservability { view: RlViewMode::Rollout } } } -} - -#[component] -pub fn TrainPage() -> Element { - rsx! { AppLayout { RlObservability { view: RlViewMode::Train } } } -} - -#[component] -pub fn RlSpansPage() -> Element { - rsx! { AppLayout { RlObservability { view: RlViewMode::Spans } } } -} - -#[component] -pub fn ProcessTimelinePage() -> Element { - rsx! { AppLayout { RlObservability { view: RlViewMode::ProcessTimeline } } } -} - -#[component] -pub fn PerfettoPage() -> Element { - rsx! { - AppLayout { - compact: true, - RlObservability { view: RlViewMode::Perfetto } - } - } -} - -#[component] -pub fn InferencePage() -> Element { - rsx! { AppLayout { Inference {} } } -} - -#[component] -pub fn DashboardPage() -> Element { - rsx! { AppLayout { Dashboard {} } } -} - -#[component] -pub fn AgentPage() -> Element { - rsx! { AppLayout { Agent {} } } -} - -#[component] -pub fn ClusterPage() -> Element { - rsx! { AppLayout { Cluster {} } } -} - -#[component] -pub fn StackPage() -> Element { - rsx! { AppLayout { Stack { tid: None } } } -} - -#[component] -pub fn StackWithTidPage(tid: String) -> Element { - rsx! { AppLayout { Stack { tid: Some(tid) } } } -} - -#[component] -pub fn StackDistributedFullPage() -> Element { - rsx! { - AppLayout { - fullscreen: true, - StackDistributed { mode: "mixed".to_string() } - } - } -} - -#[component] -pub fn StackDistributedPyPage() -> Element { - rsx! { - AppLayout { - fullscreen: true, - StackDistributed { mode: "py".to_string() } - } - } -} - -#[component] -pub fn ProfilingRedirect() -> Element { - let nav = dioxus_router::use_navigator(); - use_effect(move || { - nav.replace(Route::ProfilingViewPage { - view: "pprof".to_string(), - }); - }); - rsx! { - AppLayout { - fullscreen: true, - LoadingState { message: Some("Opening profiling…".to_string()) } - } - } -} - -#[component] -pub fn ChromeTracingRedirect() -> Element { - let nav = dioxus_router::use_navigator(); - use_effect(move || { - nav.replace(Route::ProfilingViewPage { - view: "trace".to_string(), - }); - }); - rsx! { - AppLayout { - fullscreen: true, - LoadingState { message: Some("Opening trace timeline…".to_string()) } - } - } -} - -#[component] -pub fn ProfilingViewPage(view: String) -> Element { - let canonical = normalize_profiling_view(&view).to_string(); - if view == "torch-dist" || canonical == "torch-dist" { - return rsx! { - StackDistributedRedirect {} - }; - } - if view != canonical { - return rsx! { - ProfilingSlugRedirect { target: canonical } - }; - } - - rsx! { - AppLayout { - fullscreen: true, - Profiling { key: "{canonical}", view: canonical } - } - } -} - -#[component] -fn StackDistributedRedirect() -> Element { - let nav = dioxus_router::use_navigator(); - use_effect(move || { - nav.replace(Route::StackDistributedFullPage {}); - }); - rsx! { - AppLayout { - fullscreen: true, - LoadingState { message: Some("Opening distributed stacks…".to_string()) } - } - } -} - -#[component] -fn ProfilingSlugRedirect(target: String) -> Element { - let nav = dioxus_router::use_navigator(); - use_effect(move || { - nav.replace(Route::ProfilingViewPage { - view: target.clone(), - }); - }); - rsx! { - AppLayout { - fullscreen: true, - LoadingState { message: Some("Redirecting…".to_string()) } - } - } -} - -#[component] -pub fn AnalyticsPage() -> Element { - rsx! { AppLayout { Analytics {} } } -} - -#[component] -pub fn PythonPage() -> Element { - rsx! { AppLayout { Python {} } } -} - -#[component] -pub fn TracesRedirect() -> Element { - let nav = dioxus_router::use_navigator(); - use_effect(move || { - nav.replace(Route::SpansPage {}); - }); - rsx! { - AppLayout { - fullscreen: true, - LoadingState { message: Some("Redirecting to spans…".to_string()) } - } - } -} - -#[component] -pub fn SpansPage() -> Element { - rsx! { - AppLayout { - fullscreen: true, - Traces {} - } - } -} - -#[component] -pub fn PulsingPage() -> Element { - rsx! { AppLayout { Pulsing {} } } -} - -#[component] -pub fn TrainingPage() -> Element { - rsx! { AppLayout { Training {} } } -} - -#[component] -pub fn App() -> Element { - rsx! { - AppOverlays {} - Router:: {} - } -} diff --git a/web/src/components/agent/chat.rs b/web/src/components/agent/chat.rs index 77519403..ef86d8e8 100644 --- a/web/src/components/agent/chat.rs +++ b/web/src/components/agent/chat.rs @@ -1,393 +1,19 @@ -//! Shared Investigation Agent chat (floating overlay + full page). +//! Investigation skill runner used by the Next Investigate workspace. use std::collections::HashMap; use dioxus::prelude::*; use crate::agent::{ - evaluate_rules_for_skill, format_findings, list_skill_ids, load_skill, - refresh_page_snapshot_for_route, resolve_skill_id, run_skill, select_skill, skill_store_loaded, - summarize_run, + evaluate_rules_for_skill, format_findings, load_skill, resolve_skill_id, run_skill, + select_skill, skill_store_loaded, summarize_run, }; use crate::api::ApiClient; -use crate::app::Route; -use crate::components::agent::step_card::{step_outcome_to_card, AgentSkillRunCard, AgentStepCard}; -use crate::components::colors::colors; -use crate::components::icon::Icon; -use crate::components::markdown_view::MarkdownView; -use crate::components::source_viewer::SourceRefChip; -use crate::components::workspace::{ - ChipButton, SurfaceCard, SurfaceCardBody, WidthSegment, WorkspacePanelShell, -}; -use crate::state::agent::{ - push_agent_message, save_agent_panel_width, take_agent_pending_action, AgentMessage, - AgentMessageKind, AgentPanelWidth, AgentPendingAction, AGENT_ACTION_TICK, AGENT_INPUT, - AGENT_MESSAGES, AGENT_PANEL_OPEN, AGENT_PANEL_WIDTH, -}; -use crate::state::investigation::INVESTIGATION_CONTEXT; -use crate::state::llm_config::{LlmConfig, LLM_CONFIG, LLM_SETTINGS_OPEN}; +use crate::components::agent::step_outcome_to_card; +use crate::state::agent::{push_agent_message, AgentMessage}; +use crate::state::llm_config::{LlmConfig, LLM_CONFIG}; use crate::state::page_context::PAGE_CONTEXT; -use crate::state::ui_tasks::{ui_agent_busy, UiTaskKind, UiTaskSession, UI_TASK_TICK}; -use dioxus_router::use_route; - -const QUICK_SKILLS: &[(&str, &str)] = &[ - ("health_overview", "Health"), - ("training_hang", "Hang"), - ("slow_rank", "Slow rank"), - ("nccl_culprit_victim", "NCCL"), - ("comm_bottleneck", "Comm"), - ("memory_leak", "Memory"), - ("module_bottleneck", "Bottleneck"), -]; - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum AgentChatVariant { - /// Fixed overlay on the right; does not resize main content. - Floating, - /// Full workspace page at `/agent`. - Page, -} - -#[component] -pub fn AgentChat(variant: AgentChatVariant) -> Element { - let scroll_anchor = use_signal(|| 0u32); - let messages = AGENT_MESSAGES.read().clone(); - let input_val = AGENT_INPUT.read().clone(); - let _task_tick = UI_TASK_TICK.read(); - let _agent_action_tick = AGENT_ACTION_TICK.read(); - let running = ui_agent_busy(); - let panel_width = *AGENT_PANEL_WIDTH.read(); - let llm_on = LLM_CONFIG.read().is_configured(); - - use_effect(move || { - let _ = scroll_anchor(); - let _ = _agent_action_tick; - let Some(action) = take_agent_pending_action() else { - return; - }; - match action { - AgentPendingAction::SubmitText(text) => submit_agent_text(text), - AgentPendingAction::RunSkill(id) => trigger_skill(id), - } - }); - - let subtitle = if llm_on { - format!("LLM · {}", LLM_CONFIG.read().model) - } else { - "Keyword mode · ⚙ for LLM".to_string() - }; - - let settings_btn = rsx! { - button { - class: "p-1.5 rounded-md text-gray-500 hover:bg-gray-100", - title: "LLM settings", - onclick: move |_| *LLM_SETTINGS_OPEN.write() = true, - Icon { icon: &icondata::AiSettingOutlined, class: "w-4 h-4" } - } - }; - - let header_actions = match variant { - AgentChatVariant::Floating => rsx! { - div { class: "inline-flex items-center rounded-lg border border-gray-200 bg-gray-100 p-0.5", - WidthSegment { - label: "⅓", - selected: panel_width == AgentPanelWidth::Third, - title: "Overlay width 1/3 of viewport", - onclick: move |_| save_agent_panel_width(AgentPanelWidth::Third), - } - WidthSegment { - label: "⅔", - selected: panel_width == AgentPanelWidth::TwoThirds, - title: "Overlay width 2/3 of viewport", - onclick: move |_| save_agent_panel_width(AgentPanelWidth::TwoThirds), - } - } - {settings_btn} - button { - class: "p-1.5 rounded-md text-gray-500 hover:bg-gray-100", - title: "Close overlay", - onclick: move |_| *AGENT_PANEL_OPEN.write() = false, - Icon { icon: &icondata::AiCloseOutlined, class: "w-4 h-4" } - } - }, - AgentChatVariant::Page => rsx! { - {settings_btn} - }, - }; - - let toolbar = rsx! { - div { class: "flex flex-wrap gap-1.5", - for (id, label) in QUICK_SKILLS { - ChipButton { - label: (*label).to_string(), - disabled: running, - onclick: { - let skill_id = (*id).to_string(); - move |_| spawn_run_skill(skill_id.clone(), HashMap::new(), None) - }, - } - } - } - }; - - let footer = rsx! { - div { - class: "flex gap-2", - input { - class: "flex-1 min-w-0 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-sans bg-white", - placeholder: "Describe issue or /health_overview …", - disabled: running, - value: "{input_val}", - oninput: move |e| *AGENT_INPUT.write() = e.value(), - onkeydown: move |e: dioxus::html::events::KeyboardEvent| { - use dioxus::html::input_data::keyboard_types::Key; - if e.key() == Key::Enter && !running { - submit_agent_input(); - } - }, - } - button { - class: format!( - "px-3 py-2 text-sm font-medium text-white rounded-lg bg-{} hover:opacity-90 disabled:opacity-50 shrink-0", - colors::PRIMARY - ), - disabled: running || AGENT_INPUT.read().trim().is_empty(), - onclick: move |_| submit_agent_input(), - if running { "…" } else { "Run" } - } - } - div { class: "mt-2 flex justify-between items-center text-[10px] text-gray-400", - span { - if variant == AgentChatVariant::Floating { - "⌘J toggle overlay · Enter run" - } else { - "Full-page Investigate · Enter run" - } - } - button { - class: "text-gray-500 hover:text-gray-700 underline", - disabled: running, - onclick: move |_| crate::state::agent::clear_agent_messages(), - "Clear" - } - } - }; - - rsx! { - WorkspacePanelShell { - title: "Investigate".to_string(), - subtitle: Some(subtitle), - icon: &icondata::AiRobotOutlined, - header_actions: Some(header_actions), - toolbar: Some(toolbar), - footer: footer, - embedded: variant == AgentChatVariant::Page, - div { id: "agent-scroll", - AgentPageContextCard {} - if messages.is_empty() { - AgentWelcome {} - } - for (idx, msg) in messages.iter().enumerate() { - AgentMessageView { key: "{idx}", message: msg.clone() } - } - if running { - AgentWorkingIndicator {} - } - div { id: "agent-scroll-anchor-{scroll_anchor()}" } - } - } - } -} - -#[component] -fn AgentPageContextCard() -> Element { - let page = PAGE_CONTEXT.read().clone(); - let route = use_route::(); - - rsx! { - SurfaceCard { - SurfaceCardBody { - class: "px-3 py-2 space-y-1.5 text-xs", - div { class: "flex items-start justify-between gap-2", - div { class: "min-w-0", - div { class: "font-medium text-gray-900", "Viewing: {page.title}" } - div { class: "text-[10px] text-gray-500 font-mono truncate", "{page.path}" } - p { class: "text-gray-600 mt-1 leading-relaxed", "{page.description}" } - } - button { - class: "shrink-0 px-2 py-1 text-[10px] rounded border border-gray-200 text-gray-600 hover:bg-gray-50", - title: "Refresh page snapshot for LLM", - disabled: page.snapshot_loading, - onclick: move |_| { - let route = route.clone(); - spawn(async move { - refresh_page_snapshot_for_route(route).await; - }); - }, - if page.snapshot_loading { "…" } else { "Refresh" } - } - } - if !page.suggested_skills.is_empty() { - div { class: "flex flex-wrap gap-1 pt-0.5", - span { class: "text-[10px] text-gray-400", "Suggested:" } - for id in page.suggested_skills.iter().cloned() { - ChipButton { - label: id.clone(), - disabled: ui_agent_busy(), - onclick: move |_| spawn_run_skill(id.clone(), HashMap::new(), None), - } - } - } - } - if !page.snapshot.is_empty() { - pre { class: "text-[10px] font-mono text-gray-700 bg-gray-50 rounded p-2 max-h-32 overflow-auto whitespace-pre-wrap border border-gray-100", - "{page.snapshot}" - } - } else if page.snapshot_loading { - p { class: "text-[10px] text-gray-400", "Loading page snapshot…" } - } - } - } - } -} - -#[component] -fn AgentWelcome() -> Element { - let ctx = INVESTIGATION_CONTEXT.read().clone(); - rsx! { - SurfaceCard { - SurfaceCardBody { - class: "px-3 py-3 space-y-2 text-sm text-gray-600", - p { class: "font-medium text-gray-800", "Ask in plain language or pick a quick skill above." } - ul { class: "list-disc list-inside text-xs space-y-1 text-gray-500", - li { "「训练卡住了」→ training_hang" } - li { "「哪个 rank 慢」→ slow_rank(多机自动 cluster fan-out)" } - li { "「通信慢 / NCCL」→ comm_bottleneck" } - li { "「显存在涨」→ memory_leak" } - } - if !ctx.is_empty() { - p { class: "text-xs text-blue-700 bg-blue-50 rounded px-2 py-1 font-mono", - "Context: {ctx.summary()}" - } - } - if LLM_CONFIG.read().is_configured() { - p { class: "text-xs text-emerald-700", "LLM will pick skills and summarize results." } - } else { - p { class: "text-xs text-gray-500", - "No LLM key — open ⚙ to save an API key in this browser (localStorage)." - } - } - p { class: "text-xs text-gray-400", - "Available: {list_skill_ids().join(\", \")}" - } - } - } - } -} - -#[component] -fn AgentMessageView(message: AgentMessage) -> Element { - match message.kind { - AgentMessageKind::User => rsx! { - div { class: "flex justify-end", - div { - class: "max-w-[90%] px-3 py-2 rounded-lg bg-blue-600 text-white text-sm shadow-sm", - "{message.text}" - } - } - }, - AgentMessageKind::Assistant => rsx! { - AgentAssistantBlock { text: message.text.clone() } - }, - AgentMessageKind::SkillRun => rsx! { - AgentSkillRunCard { - title: message.title.clone().unwrap_or_default(), - skill_id: message.skill_id.clone().unwrap_or_default(), - category: message.skill_category.clone().unwrap_or_default(), - docs: message.text.clone(), - } - }, - AgentMessageKind::StepCard => { - if let Some(step) = message.step.clone() { - rsx! { AgentStepCard { step } } - } else { - rsx! { div {} } - } - } - AgentMessageKind::Error => rsx! { - SurfaceCard { - SurfaceCardBody { - class: "px-3 py-2 text-sm text-red-800 bg-red-50 whitespace-pre-wrap", - "{message.text}" - } - } - }, - } -} - -#[component] -fn AgentAssistantBlock(text: String) -> Element { - let _task_tick = UI_TASK_TICK.read(); - let agent_busy = ui_agent_busy(); - let chips = extract_skill_chips(&text); - let source_refs = crate::utils::source_ref::extract_source_refs(&text); - rsx! { - div { class: "space-y-2", - SurfaceCard { - SurfaceCardBody { - class: "px-3 py-2 bg-gray-50/50", - MarkdownView { content: text } - } - } - if !source_refs.is_empty() { - div { class: "flex flex-wrap gap-1.5", - for (i, reference) in source_refs.iter().enumerate() { - SourceRefChip { - key: "{i}", - path: reference.path.clone(), - line: reference.line.map(i64::from), - } - } - } - } - if !chips.is_empty() { - div { class: "flex flex-wrap gap-1.5", - for id in chips { - ChipButton { - label: format!("Run {id}"), - disabled: agent_busy, - onclick: move |_| spawn_run_skill(id.clone(), HashMap::new(), None), - } - } - } - } - } - } -} - -fn extract_skill_chips(text: &str) -> Vec { - let mut out = Vec::new(); - for line in text.lines() { - if let Some(idx) = line.find("skill:") { - let rest = line[idx + "skill:".len()..].trim(); - let id = rest.split_whitespace().next().unwrap_or("").trim(); - if load_skill(id).is_some() && !out.contains(&id.to_string()) { - out.push(id.to_string()); - } - } - } - out -} - -#[component] -fn AgentWorkingIndicator() -> Element { - rsx! { - div { class: "flex items-center gap-2 px-3 py-2 text-xs text-gray-500", - span { class: "inline-block w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" } - "Agent working — selecting skill and running diagnostics…" - } - } -} +use crate::state::ui_tasks::{ui_agent_busy, UiTaskKind, UiTaskSession}; /// Submit a user message to the agent (opens flow / skills / LLM). pub fn submit_agent_text(text: String) { @@ -397,7 +23,7 @@ pub fn submit_agent_text(text: String) { dispatch_agent_message(text); } -/// Run a skill immediately (used from source bridge, chips, etc.). +/// Run a skill immediately (used from chips and source bridges). pub fn trigger_skill(skill_id: String) { if ui_agent_busy() { return; @@ -405,15 +31,6 @@ pub fn trigger_skill(skill_id: String) { spawn_run_skill(skill_id, HashMap::new(), None); } -fn submit_agent_input() { - let text = AGENT_INPUT.read().trim().to_string(); - if text.is_empty() || ui_agent_busy() { - return; - } - *AGENT_INPUT.write() = String::new(); - dispatch_agent_message(text); -} - fn dispatch_agent_message(text: String) { push_agent_message(AgentMessage::user(text.clone())); diff --git a/web/src/components/agent/mod.rs b/web/src/components/agent/mod.rs index eb4f512e..80fe245c 100644 --- a/web/src/components/agent/mod.rs +++ b/web/src/components/agent/mod.rs @@ -1,10 +1,8 @@ -//! Investigation Agent — skill-driven diagnostic chat panel. +//! Investigation Agent — skill-driven diagnostic helpers. pub mod chat; -pub mod panel; mod settings; mod step_card; -mod view_route; -pub use panel::AgentPanel; pub use settings::LlmSettingsOverlay; +pub use step_card::step_outcome_to_card; diff --git a/web/src/components/agent/panel.rs b/web/src/components/agent/panel.rs deleted file mode 100644 index 2efb7985..00000000 --- a/web/src/components/agent/panel.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Floating Investigation Agent overlay (does not resize main content). - -use dioxus::prelude::*; -use dioxus_router::use_route; - -use crate::app::Route; -use crate::components::agent::chat::{AgentChat, AgentChatVariant}; -use crate::state::agent::{AGENT_PANEL_OPEN, AGENT_PANEL_WIDTH}; - -#[component] -pub fn AgentPanel() -> Element { - let route = use_route::(); - if !*AGENT_PANEL_OPEN.read() || matches!(route, Route::AgentPage {}) { - return rsx! {}; - } - - let width_class = AGENT_PANEL_WIDTH.read().floating_class(); - - rsx! { - div { class: "absolute inset-0 z-40 flex pointer-events-none", - div { - class: "flex-1 bg-black/20 pointer-events-auto", - onclick: move |_| *AGENT_PANEL_OPEN.write() = false, - } - div { - class: "h-full {width_class} shrink-0 shadow-2xl pointer-events-auto", - AgentChat { variant: AgentChatVariant::Floating } - } - } - } -} diff --git a/web/src/components/agent/step_card.rs b/web/src/components/agent/step_card.rs index 5ed2a39c..54a8d0d6 100644 --- a/web/src/components/agent/step_card.rs +++ b/web/src/components/agent/step_card.rs @@ -1,85 +1,7 @@ -//! Skill step results as in-app tool cards (tables, API text, navigation). - -use dioxus::prelude::*; -use dioxus_router::Link; -use probing_proto::prelude::{DataFrame, Ele}; +//! Convert skill step outcomes into Agent message cards. use crate::agent::StepOutcome; -use crate::components::agent::view_route::{agent_view_label, agent_view_to_route}; -use crate::components::colors::colors; -use crate::components::dataframe_view::DataFrameView; -use crate::components::markdown_view::MarkdownView; -use crate::components::source_viewer::SourceRefChip; -use crate::components::stat_card::StatCard; -use crate::components::workspace::{ - AccentSurface, StatusBadge, SurfaceCardBody, SurfaceIconHeader, -}; use crate::state::agent::{AgentStepCardData, AgentStepKind, AgentStepStatus}; -use crate::state::investigation::apply_context_from_dataframe_row; -use crate::utils::source_ref::{extract_source_refs, parse_files_api_path}; - -fn status_accent(status: &AgentStepStatus) -> &'static str { - match status { - AgentStepStatus::Ok => "border-l-emerald-500", - AgentStepStatus::Warn => "border-l-amber-500", - AgentStepStatus::Skipped => "border-l-slate-300", - AgentStepStatus::Error => "border-l-red-500", - } -} - -fn status_badge(status: &AgentStepStatus) -> (&'static str, &'static str) { - match status { - AgentStepStatus::Ok => ("OK", "bg-emerald-50 text-emerald-700 border-emerald-200"), - AgentStepStatus::Warn => ("WARN", "bg-amber-50 text-amber-800 border-amber-200"), - AgentStepStatus::Skipped => ("SKIP", "bg-slate-100 text-slate-600 border-slate-200"), - AgentStepStatus::Error => ("ERR", "bg-red-50 text-red-700 border-red-200"), - } -} - -fn kind_icon(kind: &AgentStepKind) -> &'static icondata::Icon { - match kind { - AgentStepKind::Sql => &icondata::AiDatabaseOutlined, - AgentStepKind::Api => &icondata::AiApiOutlined, - AgentStepKind::Navigate => &icondata::AiLinkOutlined, - } -} - -fn ele_display(ele: &Ele) -> String { - match ele { - Ele::Nil => "—".to_string(), - Ele::BOOL(x) => x.to_string(), - Ele::I32(x) => x.to_string(), - Ele::I64(x) => x.to_string(), - Ele::F32(x) => format!("{x:.4}"), - Ele::F64(x) => format!("{x:.4}"), - Ele::Text(x) => x.clone(), - Ele::Url(x) => x.clone(), - Ele::DataTime(x) => x.to_string(), - } -} - -fn sql_headline_stats(df: &DataFrame) -> Vec<(String, String)> { - let nrows = df.cols.iter().map(|c| c.len()).max().unwrap_or(0); - if nrows == 0 || nrows > 2 { - return Vec::new(); - } - let mut stats = Vec::new(); - for (name, col) in df.names.iter().zip(df.cols.iter()) { - if stats.len() >= 4 { - break; - } - let first = col.get(0); - if matches!(first, Ele::Nil) { - continue; - } - let val = ele_display(&first); - if val == "—" || val.is_empty() { - continue; - } - stats.push((name.clone(), val)); - } - stats -} pub fn step_outcome_to_card(outcome: StepOutcome) -> AgentStepCardData { match outcome { @@ -188,218 +110,3 @@ pub fn step_outcome_to_card(outcome: StepOutcome) -> AgentStepCardData { }, } } - -#[component] -pub fn AgentSkillRunCard( - title: String, - skill_id: String, - category: String, - docs: String, -) -> Element { - let docs_preview: String = docs.lines().take(4).collect::>().join("\n"); - rsx! { - AccentSurface { - accent: "border-l-blue-500", - SurfaceIconHeader { - icon: &icondata::AiRobotOutlined, - icon_class: "w-4 h-4 text-blue-600", - title: title, - subtitle: Some(format!("{category} · {skill_id}")), - } - if !docs_preview.is_empty() { - SurfaceCardBody { - class: "px-3 py-2", - MarkdownView { - content: docs_preview, - class: "text-xs text-gray-600".to_string(), - } - } - } - } - } -} - -#[component] -pub fn AgentStepCard(step: AgentStepCardData) -> Element { - let mut expanded = use_signal(|| { - matches!(step.kind, AgentStepKind::Navigate) - || step.row_count.unwrap_or(0) > 0 - || matches!(step.status, AgentStepStatus::Error) - }); - - let (badge_label, badge_cls) = status_badge(&step.status); - let accent = status_accent(&step.status); - let kind = step.kind.clone(); - let icon = kind_icon(&kind); - - let row_badge = step.row_count.map(|n| { - if n == 0 { - "0 rows".to_string() - } else { - format!("{n} rows") - } - }); - - let headline_stats = step - .dataframe - .as_ref() - .map(sql_headline_stats) - .unwrap_or_default(); - - let navigate_view = step.navigate_view.clone(); - let view_label = navigate_view - .as_ref() - .map(|v| agent_view_label(v)) - .unwrap_or_default(); - let nav_route = navigate_view.as_ref().map(|v| agent_view_to_route(v)); - - let files_api_path = step.api_path.as_ref().and_then(|p| parse_files_api_path(p)); - let body_source_refs = extract_source_refs(&step.body_text); - - rsx! { - AccentSurface { - accent: accent, - button { - class: "w-full text-left px-3 py-2.5 bg-gray-50/80 border-b border-gray-100 hover:bg-gray-100/80 transition-colors", - onclick: move |_| { - let cur = *expanded.read(); - *expanded.write() = !cur; - }, - div { class: "flex items-center gap-2 min-w-0", - crate::components::icon::Icon { icon, class: "w-4 h-4 text-gray-600 shrink-0" } - div { class: "flex-1 min-w-0", - div { class: "text-xs font-medium text-gray-900 truncate", title: "{step.title}", - "{step.title}" - } - div { class: "text-[10px] text-gray-400 font-mono truncate", "{step.step_id}" } - } - div { class: "flex items-center gap-1 shrink-0", - StatusBadge { label: badge_label, badge_class: badge_cls } - if let Some(ref cn) = step.cluster_note { - span { - class: "text-[10px] px-1.5 py-0.5 rounded bg-violet-50 text-violet-700 border border-violet-100 max-w-[9rem] truncate", - title: "{cn}", - "{cn}" - } - } - if let Some(ref rb) = row_badge { - span { class: "text-[10px] text-gray-400", "{rb}" } - } - div { - class: "transition-transform duration-200", - class: if *expanded.read() { "rotate-180" } else { "rotate-0" }, - svg { - class: "w-3.5 h-3.5 text-gray-400", - fill: "none", - stroke: "currentColor", - view_box: "0 0 24 24", - path { - stroke_linecap: "round", - stroke_linejoin: "round", - stroke_width: "2", - d: "M19 9l-7 7-7-7" - } - } - } - } - } - } - - if *expanded.read() { - SurfaceCardBody { - class: "px-3 py-2 space-y-2", - if !headline_stats.is_empty() { - div { class: "grid grid-cols-2 gap-2", - for (label, value) in headline_stats { - StatCard { - label: label.clone(), - value: value.clone(), - } - } - } - } - - if let Some(ref view) = navigate_view { - if let Some(route) = nav_route.clone() { - div { class: "flex flex-col gap-2", - p { class: "text-xs text-gray-600", - "Open the full view in the main workspace to inspect details." - } - Link { - to: route, - class: format!( - "inline-flex items-center justify-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-{} text-white hover:opacity-90 transition-opacity", - colors::PRIMARY - ), - crate::components::icon::Icon { icon: &icondata::AiExportOutlined, class: "w-4 h-4" } - span { "Open {view_label}" } - span { class: "text-white/70 font-mono text-xs", "{view}" } - } - } - } - } else if let Some(ref df) = step.dataframe { - if step.row_count.unwrap_or(0) > 0 { - { - let df_for_click = df.clone(); - rsx! { - p { class: "text-[10px] text-gray-500 mb-1", - "Click a row to set investigation context (tid / trace_id / span columns)." - } - div { class: "max-h-56 overflow-auto rounded-md border border-gray-100 text-xs", - DataFrameView { - df: df.clone(), - on_row_click: EventHandler::new(move |row: usize| { - apply_context_from_dataframe_row(&df_for_click, row); - }), - } - } - } - } - } else if !step.body_text.is_empty() { - MarkdownView { - content: step.body_text.clone(), - class: "text-xs text-amber-800".to_string(), - } - } - } else if !step.body_text.is_empty() { - if let Some(ref file_path) = files_api_path { - div { class: "flex flex-wrap gap-1.5", - SourceRefChip { - path: file_path.clone(), - line: None, - } - } - } else if matches!(step.kind, AgentStepKind::Api) { - pre { class: "text-[11px] font-mono text-gray-800 bg-gray-50 rounded-md p-2 overflow-x-auto whitespace-pre-wrap max-h-48 border border-gray-100", - "{step.body_text}" - } - } else { - MarkdownView { - content: step.body_text.clone(), - class: "text-xs".to_string(), - } - } - } - - if !body_source_refs.is_empty() { - div { class: "flex flex-wrap gap-1.5 pt-1", - for (i, reference) in body_source_refs.iter().enumerate() { - SourceRefChip { - key: "{i}", - path: reference.path.clone(), - line: reference.line.map(i64::from), - } - } - } - } - - if let Some(ref path) = step.api_path { - if files_api_path.is_none() { - p { class: "text-[10px] text-gray-400 font-mono truncate", "GET {path}" } - } - } - } - } - } - } -} diff --git a/web/src/components/agent/view_route.rs b/web/src/components/agent/view_route.rs deleted file mode 100644 index 8cbf92ab..00000000 --- a/web/src/components/agent/view_route.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Map skill `ui.view` targets to app routes (shared with sidebar navigation). - -use crate::app::Route; -use crate::state::profiling::{normalize_profiling_view, profiling_view_label}; - -pub fn agent_view_to_route(view: &str) -> Route { - let v = view.trim().trim_start_matches("profiling/"); - match v { - "analytics" => Route::AnalyticsPage {}, - "pprof" => Route::ProfilingViewPage { - view: "pprof".to_string(), - }, - "torch" => Route::ProfilingViewPage { - view: "torch".to_string(), - }, - "traces" | "spans" => Route::SpansPage {}, - "trace" | "chrome-trace" => Route::ProfilingViewPage { - view: "trace".to_string(), - }, - "python" => Route::PythonPage {}, - "training" => Route::TrainingPage {}, - "cluster" => Route::ClusterPage {}, - other if other.starts_with("profiling/") => Route::ProfilingViewPage { - view: other.replace("profiling/", ""), - }, - other => Route::ProfilingViewPage { - view: normalize_profiling_view(other).to_string(), - }, - } -} - -pub fn agent_view_label(view: &str) -> String { - let v = view.trim().trim_start_matches("profiling/"); - match v { - "analytics" => "Analytics".to_string(), - "traces" | "spans" => "Spans".to_string(), - "trace" | "chrome-trace" => "Chrome trace".to_string(), - "python" => "Python".to_string(), - "training" => "Training".to_string(), - "cluster" => "Cluster".to_string(), - other => profiling_view_label(other).to_string(), - } -} diff --git a/web/src/components/card_view.rs b/web/src/components/card_view.rs deleted file mode 100644 index 071e9ece..00000000 --- a/web/src/components/card_view.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::app::Route; -use dioxus::prelude::*; -use dioxus_router::Link; - -#[component] -pub fn ThreadsCard(threads: Vec) -> Element { - rsx! { - div { - class: "flex flex-wrap gap-2", - div { - class: "text-xs text-gray-500 mb-2", - "Debug: ThreadsCard received {threads.len()} threads" - } - if threads.is_empty() { - span { - class: "text-gray-500 italic", - "No threads found" - } - } else { - for tid in threads { - Link { - to: Route::StackPage {}, - button { - class: "px-3 py-1 text-sm bg-blue-100 text-blue-800 hover:bg-blue-200 rounded-md transition-colors", - "{tid}" - } - } - } - } - } - } -} diff --git a/web/src/components/collapsible_card.rs b/web/src/components/collapsible_card.rs deleted file mode 100644 index 5269cff3..00000000 --- a/web/src/components/collapsible_card.rs +++ /dev/null @@ -1,85 +0,0 @@ -use dioxus::prelude::*; - -use crate::components::colors::colors; - -#[component] -pub fn CollapsibleCardWithIcon( - title: String, - icon: Element, - children: Element, - #[props(optional)] badge: Option, - #[props(optional)] badge_classes: Option, - #[props(optional)] accent_border: Option, - #[props(optional, default = false)] default_open: bool, -) -> Element { - let mut is_open = use_signal(|| default_open); - let border_accent = accent_border.unwrap_or_else(|| "border-l-transparent".to_string()); - let badge_style = badge_classes.unwrap_or_else(|| { - format!( - "bg-{} text-{} border-{}", - colors::CONTENT_ACCENT_BG, - colors::CONTENT_ACCENT_TEXT, - colors::CONTENT_ACCENT_BORDER - ) - }); - - rsx! { - div { - class: format!( - "border border-gray-200 rounded-lg bg-white border-l-4 {border_accent} shadow-sm" - ), - div { - class: format!( - "px-4 py-3 bg-{} border-b border-{} cursor-pointer hover:bg-{} transition-colors", - colors::CONTENT_BG, - colors::CONTENT_BORDER, - colors::BTN_SECONDARY_BG - ), - onclick: move |_| { - let current = *is_open.read(); - *is_open.write() = !current; - }, - div { - class: "flex items-center justify-between gap-3", - div { - class: "flex items-center gap-2 min-w-0", - {icon} - span { - class: "text-sm font-medium text-gray-900 font-mono truncate", - title: "{title}", - "{title}" - } - if let Some(label) = badge { - span { - class: "shrink-0 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide border {badge_style}", - "{label}" - } - } - } - div { - class: "transition-transform duration-200 shrink-0", - class: if *is_open.read() { "rotate-180" } else { "rotate-0" }, - svg { - class: "w-4 h-4 text-gray-500", - fill: "none", - stroke: "currentColor", - view_box: "0 0 24 24", - path { - stroke_linecap: "round", - stroke_linejoin: "round", - stroke_width: "2", - d: "M19 9l-7 7-7-7" - } - } - } - } - } - if *is_open.read() { - div { - class: "p-4", - {children} - } - } - } - } -} diff --git a/web/src/components/common.rs b/web/src/components/common.rs index 260547b1..76177c18 100644 --- a/web/src/components/common.rs +++ b/web/src/components/common.rs @@ -81,21 +81,3 @@ pub fn AppErrorDisplay(error: AppError, #[props(optional)] title: Option } } } - -/// Match a resolved API [`Result`] into error, empty, or success UI. -pub fn query_result( - result: Result, - is_empty: impl FnOnce(&T) -> bool, - empty_message: &str, - render: impl FnOnce(T) -> Element, -) -> Element { - match result { - Ok(value) if is_empty(&value) => rsx! { - EmptyState { message: empty_message.to_string() } - }, - Ok(value) => render(value), - Err(err) => rsx! { - AppErrorDisplay { error: err, title: None } - }, - } -} diff --git a/web/src/components/cpu_threads_table.rs b/web/src/components/cpu_threads_table.rs deleted file mode 100644 index 41d107c1..00000000 --- a/web/src/components/cpu_threads_table.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Top CPU threads — name-first table with stack drill-down. - -use dioxus::prelude::*; -use dioxus_router::use_navigator; - -use crate::api::{format_cpu_ms, CpuThreadRow}; -use crate::app::Route; -use crate::components::colors::colors; -use crate::state::investigation::{set_thread_context, INVESTIGATION_CONTEXT}; - -#[component] -pub fn CpuThreadsTable(threads: Vec) -> Element { - let navigator = use_navigator(); - let pid = INVESTIGATION_CONTEXT.read().pid; - - rsx! { - div { class: "overflow-x-auto", - table { class: "w-full border-collapse text-sm", - thead { - tr { class: "border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500", - th { class: "py-2 pr-4 font-medium", "Thread" } - th { class: "py-2 pr-4 font-medium", "State" } - th { class: "py-2 pr-4 font-medium text-right", "User" } - th { class: "py-2 pr-4 font-medium text-right", "Kernel" } - th { class: "py-2 pr-4 font-medium", "Waiting on" } - th { class: "py-2 font-medium text-right", "Actions" } - } - } - tbody { - for row in threads { - { - let tid = row.tid; - let tid_for_stack = row.tid.to_string(); - let name = row.name.clone(); - let has_named = !row.name.starts_with("thread-"); - rsx! { - tr { class: "border-b border-gray-100 last:border-0 hover:bg-gray-50", - td { class: "py-3 pr-4 align-top", - div { class: "font-medium text-gray-900", "{row.name}" } - if has_named { - p { class: "text-xs text-gray-400 font-mono mt-0.5", "tid {tid}" } - } - } - td { class: "py-3 pr-4 align-top", - span { - class: "inline-flex px-2 py-0.5 rounded text-xs font-mono bg-gray-100 text-gray-700", - "{row.state}" - } - } - td { class: "py-3 pr-4 align-top text-right font-mono text-blue-700", - "{format_cpu_ms(row.delta_user_ns)}" - } - td { class: "py-3 pr-4 align-top text-right font-mono text-amber-700", - "{format_cpu_ms(row.delta_sys_ns)}" - } - td { class: "py-3 pr-4 align-top text-gray-500 font-mono text-xs", - {row.wchan.clone().unwrap_or_else(|| "—".to_string())} - } - td { class: "py-3 align-top text-right", - div { class: "inline-flex flex-wrap justify-end gap-2", - button { - class: format!( - "text-xs font-medium text-{} hover:underline whitespace-nowrap", - colors::PRIMARY - ), - onclick: move |_| { - navigator.push(Route::StackWithTidPage { - tid: tid_for_stack.clone(), - }); - }, - "Stack" - } - button { - class: "text-xs font-medium text-gray-600 hover:underline whitespace-nowrap", - onclick: { - let name = name.clone(); - move |_| { - set_thread_context(tid, Some(&name), pid); - navigator.push(Route::SpansPage {}); - } - }, - "Spans" - } - button { - class: format!( - "text-xs font-medium text-{} hover:underline whitespace-nowrap", - colors::CONTENT_ACCENT_TEXT - ), - onclick: { - let name = name.clone(); - move |_| { - set_thread_context(tid, Some(&name), pid); - navigator.push(Route::ProfilingViewPage { - view: "pprof".to_string(), - }); - } - }, - "Profile" - } - } - } - } - } - } - } - } - } - } - } -} diff --git a/web/src/components/global_command_panel.rs b/web/src/components/global_command_panel.rs index 6b5134ef..39dc196b 100644 --- a/web/src/components/global_command_panel.rs +++ b/web/src/components/global_command_panel.rs @@ -1,16 +1,13 @@ -//! Global Command Panel (VS Code style) and floating result. +//! Global Command Panel (VS Code style) and floating result toast. //! -//! Open via Commands button in command bar. Select command to fill input (↑↓ + Enter). -//! Edit in input bar, then Run to execute. +//! Open via ⌘K. Select/Enter executes the command via `ApiClient::eval`. use dioxus::prelude::*; use crate::api::{ApiClient, MagicGroup, MagicItem}; -use crate::components::colors::colors; use crate::hooks::use_api; -use crate::state::agent::AGENT_PANEL_OPEN; use crate::state::commands::{ - Cell, EvalState, FloatingResult, COMMAND_INPUT, COMMAND_PANEL_OPEN, EVAL_HISTORY, + Cell, EvalState, FloatingResult, COMMAND_PANEL_OPEN, EVAL_HISTORY, FLOATING_RESULT, SHORTCUTS_HELP_OPEN, }; @@ -43,6 +40,45 @@ fn filter_magics(items: &[(String, MagicItem)], query: &str) -> Vec<(String, Mag .collect() } +fn execute_command(code: String) { + let code = code.trim().to_string(); + if code.is_empty() { + return; + } + *COMMAND_PANEL_OPEN.write() = false; + spawn(async move { + let client = ApiClient::new(); + let eval_state = match client.eval(&code).await { + Ok(resp) => { + let mut text = resp.output; + if !resp.traceback.is_empty() { + text.push('\n'); + text.push_str(&resp.traceback.join("\n")); + } + EvalState { + output: text, + is_error: resp.status == "error" || !resp.traceback.is_empty(), + } + } + Err(e) => EvalState { + output: e.display_message(), + is_error: true, + }, + }; + + EVAL_HISTORY.write().push(Cell { + input: code.clone(), + output: eval_state.clone(), + }); + + *FLOATING_RESULT.write() = Some(FloatingResult { + command: code, + output: eval_state.output, + is_error: eval_state.is_error, + }); + }); +} + #[component] fn CommandPanelItem( cmd: String, @@ -72,38 +108,7 @@ fn CommandPanelItem( } } -#[component] -fn HistoryItem( - command: String, - output: String, - is_error: bool, - history_open: Signal, - on_show: EventHandler, -) -> Element { - let cmd = command.clone(); - rsx! { - button { - class: "w-full text-left px-3 py-2 hover:bg-gray-50 text-sm font-mono truncate", - onclick: move |_| { - *history_open.write() = false; - on_show.call(FloatingResult { - command: cmd.clone(), - output: output.clone(), - is_error, - }); - }, - "{command}" - } - } -} - -/// Fill input and close. Does NOT execute. -fn fill_input_and_close(command: String) { - *COMMAND_INPUT.write() = command; - *COMMAND_PANEL_OPEN.write() = false; -} - -/// Global Command Panel overlay. On select: fill input, close. Arrow keys to navigate, Enter to confirm. +/// Global Command Panel overlay. Select / Enter runs the command and shows a toast. #[component] pub fn GlobalCommandPanel() -> Element { let mut panel_query = use_signal(String::new); @@ -137,9 +142,16 @@ pub fn GlobalCommandPanel() -> Element { } let current_highlight = *highlight_idx.read(); + let history_preview: Vec<(String, bool)> = EVAL_HISTORY + .read() + .iter() + .rev() + .take(5) + .map(|h| (h.input.clone(), h.output.is_error)) + .collect(); let on_select = EventHandler::new(move |selected: String| { - fill_input_and_close(selected); + execute_command(selected); }); rsx! { @@ -153,7 +165,7 @@ pub fn GlobalCommandPanel() -> Element { r#type: "text", autofocus: true, class: "w-full px-4 py-3 text-sm font-mono border-b border-gray-200 focus:outline-none focus:ring-0", - placeholder: "Type to search... ↑↓ navigate, Enter to fill input", + placeholder: "Type to search… ↑↓ navigate, Enter runs the command", value: "{query}", oninput: move |e| { *panel_query.write() = e.value(); @@ -167,7 +179,12 @@ pub fn GlobalCommandPanel() -> Element { if !items_to_show.is_empty() { let idx = current_highlight.min(items_to_show.len() - 1); let cmd = items_to_show[idx].1.clone(); - fill_input_and_close(cmd); + execute_command(cmd); + } else { + let typed = panel_query.read().trim().to_string(); + if !typed.is_empty() { + execute_command(typed); + } } } else if e.key() == Key::ArrowDown { e.prevent_default(); @@ -190,7 +207,7 @@ pub fn GlobalCommandPanel() -> Element { } else if all_items.is_empty() { div { class: "px-4 py-6 text-sm text-gray-500", "No magics (REPL not ready)" } } else if items_to_show.is_empty() { - div { class: "px-4 py-6 text-sm text-gray-500", "No matching commands" } + div { class: "px-4 py-6 text-sm text-gray-500", "No matching commands — press Enter to run typed text" } } else { for (i, row) in items_to_show.iter().enumerate() { CommandPanelItem { @@ -203,164 +220,57 @@ pub fn GlobalCommandPanel() -> Element { } } } - } - } - } -} - -/// Command bar: input + Run + History. Execute on Run or Enter. History recalls past results. -#[component] -pub fn CommandBar(on_execute_done: EventHandler) -> Element { - let mut loading = use_signal(|| false); - let mut history_open = use_signal(|| false); - let input_val = COMMAND_INPUT.read().clone(); - let history_items: Vec<(String, String, bool)> = EVAL_HISTORY - .read() - .iter() - .rev() - .take(20) - .map(|h| (h.input.clone(), h.output.output.clone(), h.output.is_error)) - .collect(); - - let do_run = EventHandler::new(move |_: ()| { - let code = COMMAND_INPUT.read().trim().to_string(); - if code.is_empty() { - return; - } - *loading.write() = true; - spawn(async move { - let client = ApiClient::new(); - let result = client.eval(&code).await; - - let eval_state = match result { - Ok(resp) => { - let mut text = resp.output; - if !resp.traceback.is_empty() { - text.push('\n'); - text.push_str(&resp.traceback.join("\n")); - } - EvalState { - output: text.clone(), - is_error: resp.status == "error" || !resp.traceback.is_empty(), - } - } - Err(e) => EvalState { - output: e.display_message(), - is_error: true, - }, - }; - - EVAL_HISTORY.write().push(Cell { - input: code.clone(), - output: eval_state.clone(), - }); - - on_execute_done.call(FloatingResult { - command: code.clone(), - output: eval_state.output, - is_error: eval_state.is_error, - }); - *COMMAND_INPUT.write() = String::new(); - *loading.write() = false; - }); - }); - - rsx! { - div { - class: "flex items-center gap-2 px-4 py-2 bg-white border-b border-gray-200", - button { - class: format!("shrink-0 px-3 py-2 rounded-lg text-sm font-medium bg-{} text-white hover:opacity-90", colors::PRIMARY), - title: "Open command palette (⌘K)", - onclick: move |_| *COMMAND_PANEL_OPEN.write() = true, - "⌘K" - } - button { - class: if *AGENT_PANEL_OPEN.read() { - "shrink-0 px-2.5 py-2 rounded-lg text-sm font-medium bg-blue-100 text-blue-800 border border-blue-300" - } else { - "shrink-0 px-2.5 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100 border border-gray-300" - }, - title: "Investigate (⌘J) — skill diagnostic agent overlay", - onclick: move |_| { - let open = *AGENT_PANEL_OPEN.read(); - *AGENT_PANEL_OPEN.write() = !open; - }, - "Investigate" - } - button { - class: "shrink-0 px-2.5 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100 border border-gray-300", - title: "Keyboard shortcuts", - onclick: move |_| *SHORTCUTS_HELP_OPEN.write() = true, - "?" - } - input { - class: "flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500", - placeholder: "%trace list | %inspect ls modules | ...", - value: "{input_val}", - oninput: move |e| *COMMAND_INPUT.write() = e.value(), - onkeydown: move |e: dioxus::html::events::KeyboardEvent| { - use dioxus::html::input_data::keyboard_types::Key; - if e.key() == Key::Enter { - do_run.call(()); - } - }, - } - button { - class: "px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 shrink-0", - disabled: *loading.read() || COMMAND_INPUT.read().trim().is_empty(), - onclick: move |_| do_run.call(()), - if *loading.read() { "…" } else { "Run" } - } - div { - class: "relative shrink-0", - button { - class: "px-3 py-2 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100 border border-gray-300 flex items-center gap-2", - disabled: history_items.is_empty(), - onclick: move |_| { - let v = *history_open.read(); - *history_open.write() = !v; - }, - "History" - span { - class: "text-xs text-gray-500 font-normal", - "({history_items.len()})" - } - } - if *history_open.read() && !history_items.is_empty() { - div { - class: "fixed inset-0 z-[9996]", - onclick: move |_| *history_open.write() = false, - } + if !history_preview.is_empty() { div { - class: "absolute top-full right-0 mt-1 w-80 max-h-72 overflow-y-auto py-1 bg-white border border-gray-200 rounded-lg shadow-lg z-[9997]", - for item in history_items.iter() { - HistoryItem { - command: item.0.clone(), - output: item.1.clone(), - is_error: item.2, - history_open, - on_show: on_execute_done, + class: "border-t border-gray-100 bg-gray-50 px-4 py-2", + div { class: "mb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400", "Recent" } + div { class: "flex flex-col gap-0.5", + for (cmd, is_error) in history_preview { + button { + class: if is_error { + "w-full truncate text-left font-mono text-xs text-red-600 hover:underline" + } else { + "w-full truncate text-left font-mono text-xs text-gray-600 hover:underline" + }, + onclick: { + let cmd = cmd.clone(); + move |_| execute_command(cmd.clone()) + }, + "{cmd}" + } } } } } + div { + class: "flex items-center justify-between border-t border-gray-100 px-4 py-2 text-xs text-gray-400", + span { "Enter runs · Esc closes" } + button { + class: "hover:text-gray-600", + onclick: move |_| { + *COMMAND_PANEL_OPEN.write() = false; + *SHORTCUTS_HELP_OPEN.write() = true; + }, + "Shortcuts ?" + } + } } } } } -/// Centered modal showing execution result (like command panel style) +/// Centered modal showing execution result. Mounted from NextShell so it outlives the panel. #[component] -pub fn FloatingResultToast(result: Signal>) -> Element { - let opt = result.read().clone(); +pub fn FloatingResultToast() -> Element { + let opt = FLOATING_RESULT.read().clone(); if let Some(ref fr) = opt { let output = fr.output.clone(); let is_error = fr.is_error; let command = fr.command.clone(); rsx! { div { - class: "fixed inset-0 z-[9997] flex items-start justify-center pt-[10vh] bg-black/20", - onclick: move |_| *result.write() = None, + class: "fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] bg-black/20", + onclick: move |_| *FLOATING_RESULT.write() = None, div { class: "w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden rounded-lg shadow-2xl border border-gray-200 bg-white flex flex-col", onclick: move |e| { e.stop_propagation(); }, @@ -381,7 +291,7 @@ pub fn FloatingResultToast(result: Signal>) -> Element { class: "px-4 py-2 border-t border-gray-200 flex justify-end", button { class: "px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg", - onclick: move |_| *result.write() = None, + onclick: move |_| *FLOATING_RESULT.write() = None, "Close" } } diff --git a/web/src/components/investigation_context_hint.rs b/web/src/components/investigation_context_hint.rs deleted file mode 100644 index 2864db08..00000000 --- a/web/src/components/investigation_context_hint.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Inline hint when a page should reflect the global investigation context. - -use dioxus::prelude::*; -use dioxus_router::Link; - -use crate::app::Route; -use crate::state::investigation::INVESTIGATION_CONTEXT; - -#[component] -pub fn InvestigationContextHint( - #[props(default = "Process-wide view.")] note: &'static str, -) -> Element { - let ctx = INVESTIGATION_CONTEXT.read().clone(); - if ctx.trace_id.is_none() && ctx.span_name.is_none() && ctx.pid.is_none() { - return rsx! {}; - } - - rsx! { - div { - class: "mb-3 flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-2 text-xs rounded-md border border-blue-100 bg-blue-50/80 text-blue-900", - span { class: "font-medium shrink-0", "Investigation context" } - span { class: "font-mono truncate max-w-md", "{ctx.summary()}" } - if ctx.trace_id.is_some() || ctx.span_name.is_some() { - Link { - to: Route::SpansPage {}, - class: "shrink-0 px-2 py-0.5 rounded border border-blue-200 bg-white text-blue-700 hover:bg-blue-50", - "Spans" - } - } - span { class: "text-blue-700/90", "{note}" } - } - } -} diff --git a/web/src/components/layout.rs b/web/src/components/layout.rs deleted file mode 100644 index 3f5c2b48..00000000 --- a/web/src/components/layout.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! App shell: sidebar (or show-sidebar button when collapsed) + main content area. -//! All page content is rendered inside the main area with consistent padding and max-width. -//! Command Panel and floating result overlay are rendered in the main area. - -use dioxus::prelude::*; - -use crate::api::ApiClient; -use crate::components::agent::{AgentPanel, LlmSettingsOverlay}; -use crate::components::global_command_panel::{ - CommandBar, FloatingResultToast, GlobalCommandPanel, -}; -use crate::components::icon::Icon; -use crate::components::keyboard_shortcuts::{GlobalShortcutInstaller, ShortcutsHelpOverlay}; -use crate::components::page_context_sync::PageContextSync; -use crate::components::sidebar::Sidebar; -use crate::components::ui_task_runtime::UiTaskRuntime; -use crate::state::agent::load_agent_panel_width; -use crate::state::commands::{FloatingResult, COMMAND_PANEL_OPEN}; -use crate::state::investigation::load_investigation_context; -use crate::state::investigation_url::InvestigationUrlSync; -use crate::state::llm_config::load_llm_config; -use crate::state::sidebar::{save_sidebar_state, SIDEBAR_HIDDEN, SIDEBAR_WIDTH}; - -/// Floating button shown when sidebar is hidden. Kept as a const for clarity and reuse. -const SHOW_SIDEBAR_BUTTON_CLASS: &str = "fixed top-4 left-4 z-50 w-10 h-10 bg-white border border-gray-300 rounded-lg shadow-sm flex items-center justify-center hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"; - -/// When true, main content fills the viewport without max-width or padding (profiling, stacks). -/// When compact is true, chrome is hidden for embedded viewers (Perfetto). -#[component] -pub fn AppLayout( - children: Element, - #[props(default = false)] fullscreen: bool, - #[props(default = false)] compact: bool, -) -> Element { - let _sidebar_width = SIDEBAR_WIDTH.read(); - let sidebar_hidden = SIDEBAR_HIDDEN.read(); - let mut floating_result = use_signal(|| Option::::None); - - use_effect(move || { - load_investigation_context(); - load_llm_config(); - load_agent_panel_width(); - spawn(async move { - let _ = ApiClient::new().load_skill_store().await; - }); - }); - - rsx! { - GlobalShortcutInstaller {} - UiTaskRuntime {} - InvestigationUrlSync {} - PageContextSync {} - if *COMMAND_PANEL_OPEN.read() && !compact { - GlobalCommandPanel {} - } - ShortcutsHelpOverlay {} - LlmSettingsOverlay {} - FloatingResultToast { - result: floating_result, - } - - div { - class: "flex h-screen bg-gray-50 overflow-hidden", - if !*sidebar_hidden && !compact { - Sidebar {} - } else if !compact { - button { - class: SHOW_SIDEBAR_BUTTON_CLASS, - title: "Show Sidebar", - aria_label: "Show sidebar", - onclick: move |_| { - *SIDEBAR_HIDDEN.write() = false; - save_sidebar_state(); - }, - Icon { - icon: &icondata::AiMenuUnfoldOutlined, - class: "w-5 h-5 text-gray-600" - } - } - } - div { - class: "flex-1 flex flex-col min-w-0 min-h-0", - if !compact { - CommandBar { - on_execute_done: move |r| *floating_result.write() = Some(r), - } - } - div { - class: "flex-1 min-h-0 relative overflow-hidden", - main { - class: if compact { - "absolute inset-0 overflow-hidden bg-white" - } else { - "absolute inset-0 overflow-y-auto p-4 sm:p-6 bg-gray-50 min-w-0" - }, - if compact { - div { - class: "h-full w-full min-h-0", - {children} - } - } else if fullscreen { - div { class: "w-full h-full min-h-0", {children} } - } else { - div { - class: "max-w-7xl mx-auto w-full", - {children} - } - } - } - if !compact { - AgentPanel {} - } - } - } - } - } -} diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index bb1c1579..b0812c9e 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -1,46 +1,34 @@ //! Reusable UI building blocks. See `DESIGN.md` for layout and color conventions. //! -//! - **layout** — App shell (sidebar + main area). -//! - **sidebar** — Navigation, logo, Profiling submenu. -//! - **page** — PageTitle, PageContainer for content pages. +//! - **agent** — Investigation skill runner helpers and LLM settings overlay. //! - **card** — Card with optional header_right. //! - **common** — LoadingState, ErrorState, EmptyState. //! - **colors** — Tailwind color constants. //! - **table_view** / **dataframe_view** — Tables. -//! - **data** — KeyValueList and similar. -//! - **icon** — Icon component. -//! - **collapsible_card** / **card_view** / **callstack_view** / **value_list** — Domain helpers. -//! - **timeline_viewer** — Native Chrome trace timeline + Perfetto export. //! - **flamegraph** — Native flamegraph visualizations. +//! - **timeline_viewer** — Native Chrome trace timeline + Perfetto export. +//! - **profiling_controls** — Capture controls shared with the Next sidebar. pub mod agent; pub mod app_overlays; pub mod callstack_view; pub mod card; -pub mod card_view; -pub mod collapsible_card; pub mod colors; pub mod common; -pub mod cpu_threads_table; pub mod data; pub mod dataframe_view; pub mod flamegraph; pub mod global_command_panel; pub mod icon; -pub mod investigation_context_hint; pub mod keyboard_shortcuts; -pub mod layout; pub mod markdown_view; pub mod overhead; pub mod overlay_shell; -pub mod page; -pub mod page_context_sync; pub mod poll_status; pub mod profile_snapshot_bar; pub mod profiling; -pub mod profiling_sidebar_hint; +pub mod profiling_controls; pub mod rl; -pub mod sidebar; pub mod source_viewer; pub mod span_timeline; pub mod stat_card; diff --git a/web/src/components/overhead/mod.rs b/web/src/components/overhead/mod.rs index 803d7bf7..fdcdbb1a 100644 --- a/web/src/components/overhead/mod.rs +++ b/web/src/components/overhead/mod.rs @@ -2,6 +2,4 @@ mod panel; -pub use crate::api::OVERHEAD_POLL_MS; -pub use crate::overhead::table_missing_trigger_label; pub use panel::TorchOverheadPanel; diff --git a/web/src/components/page.rs b/web/src/components/page.rs deleted file mode 100644 index 3a1adbf9..00000000 --- a/web/src/components/page.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Page-level structure: title block and content container. -//! Every content page should use PageContainer and usually PageTitle. - -use dioxus::prelude::*; - -use crate::components::icon::Icon; -use icondata::Icon as IconData; - -/// Page heading with optional icon and subtitle. Place at top of page content. -#[component] -pub fn PageTitle( - title: String, - subtitle: Option, - #[props(optional)] icon: Option<&'static IconData>, - #[props(optional)] header_right: Option, -) -> Element { - rsx! { - div { - class: "mb-4 flex flex-wrap items-start justify-between gap-x-4 gap-y-1", - div { - div { - class: "flex items-center gap-2", - if let Some(icon_data) = icon { - Icon { icon: icon_data, class: "w-5 h-5 text-blue-600" } - } - h1 { - class: "text-xl font-semibold text-gray-900", - "{title}" - } - } - if let Some(subtitle) = subtitle { - p { - class: "text-sm text-gray-500 mt-0.5", - "{subtitle}" - } - } - } - if let Some(right) = header_right { - div { class: "shrink-0 pt-1", {right} } - } - } - } -} - -/// Wrapper for page content with consistent vertical spacing. Use as the root of each page’s rsx. -#[component] -pub fn PageContainer(children: Element) -> Element { - rsx! { - div { - class: "space-y-4", - {children} - } - } -} diff --git a/web/src/components/page_context_sync.rs b/web/src/components/page_context_sync.rs deleted file mode 100644 index aa5ae967..00000000 --- a/web/src/components/page_context_sync.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Keeps PAGE_CONTEXT in sync with the active route and refreshes snapshots. - -use dioxus::prelude::*; -use dioxus_router::use_route; - -use crate::agent::page_tools::{ - describe_route, refresh_page_snapshot_for_route, refresh_page_snapshot_quiet, -}; -use crate::app::Route; -use crate::state::investigation::investigation_context_key; -use crate::state::investigation::INVESTIGATION_CONTEXT; -use crate::state::page_context::{ - apply_page_descriptor, PageContextDescriptor, CURRENT_ROUTE, PAGE_CONTEXT, -}; - -#[component] -pub fn PageContextSync() -> Element { - let route = use_route::(); - let investigation = INVESTIGATION_CONTEXT.read().clone(); - - use_effect(move || { - let route = route.clone(); - let investigation = investigation.clone(); - *CURRENT_ROUTE.write() = Some(route.clone()); - let desc = describe_route(&route); - let old_page_id = PAGE_CONTEXT.read().page_id.clone(); - apply_page_descriptor(PageContextDescriptor { - page_id: desc.page_id, - title: desc.title, - path: desc.path, - description: desc.description, - suggested_skills: desc.suggested_skills, - investigation_summary: investigation.summary(), - investigation_coordinates: investigation.coordinates_summary(), - investigation_key: investigation_context_key(&investigation), - }); - let route_changed = old_page_id != PAGE_CONTEXT.read().page_id; - if route_changed { - spawn(async move { - refresh_page_snapshot_for_route(route).await; - }); - } else { - spawn(async move { - refresh_page_snapshot_quiet(route).await; - }); - } - }); - - rsx! {} -} diff --git a/web/src/components/profiling/mod.rs b/web/src/components/profiling/mod.rs index 823f188d..b7ef94af 100644 --- a/web/src/components/profiling/mod.rs +++ b/web/src/components/profiling/mod.rs @@ -5,9 +5,7 @@ mod sections; mod timeline; pub use feedback::ProfilingFeedbackToast; -pub use sections::{ - ProfilerDisabledNotice, ProfilingContentPanel, ProfilingErrorPanel, TimelinePlaceholder, -}; +pub use sections::{ProfilerDisabledNotice, ProfilingErrorPanel, TimelinePlaceholder}; pub use timeline::{ PytorchChromeTimelineLoader, RayChromeTimelineLoader, TraceChromeTimelineLoader, }; diff --git a/web/src/components/profiling/sections.rs b/web/src/components/profiling/sections.rs index 20bb4dac..ce7f66f8 100644 --- a/web/src/components/profiling/sections.rs +++ b/web/src/components/profiling/sections.rs @@ -4,16 +4,6 @@ use dioxus::prelude::*; use crate::components::common::{EmptyState, ErrorState}; -#[component] -pub fn ProfilingContentPanel(children: Element) -> Element { - rsx! { - div { - class: "flex flex-col flex-1 min-h-0 bg-white rounded-lg border border-gray-200 overflow-hidden", - {children} - } - } -} - #[component] pub fn ProfilingCenteredPanel(children: Element) -> Element { rsx! { diff --git a/web/src/components/sidebar/profiling/controls.rs b/web/src/components/profiling_controls/mod.rs similarity index 100% rename from web/src/components/sidebar/profiling/controls.rs rename to web/src/components/profiling_controls/mod.rs diff --git a/web/src/components/profiling_sidebar_hint.rs b/web/src/components/profiling_sidebar_hint.rs deleted file mode 100644 index 2e94fdde..00000000 --- a/web/src/components/profiling_sidebar_hint.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! Hint when profiling controls live in the (possibly hidden) sidebar. - -use dioxus::prelude::*; - -use crate::state::sidebar::SIDEBAR_HIDDEN; - -#[component] -pub fn ProfilingSidebarHint() -> Element { - if !*SIDEBAR_HIDDEN.read() { - return rsx! {}; - } - - rsx! { - div { - class: "mb-3 px-3 py-2 text-xs rounded-md border border-amber-200 bg-amber-50 text-amber-900", - "Profiling controls (sampling frequency, timeline reload), background tasks, and Torch overhead are in the left sidebar Monitor section — use the menu button (top-left) to show it." - } - } -} diff --git a/web/src/components/sidebar/advanced/mod.rs b/web/src/components/sidebar/advanced/mod.rs deleted file mode 100644 index a1553298..00000000 --- a/web/src/components/sidebar/advanced/mod.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Advanced / low-frequency navigation items. - -use dioxus::prelude::*; -use dioxus_router::{Link, use_route}; -use icondata::Icon as IconData; - -use crate::app::Route; -use crate::components::sidebar::nav_item::sidebar_item_class; -use crate::components::icon::Icon; - -#[component] -pub fn AdvancedSidebarItem(show_dropdown: Signal) -> Element { - let route = use_route::(); - let is_active = matches!( - route, - Route::DashboardPage {} - | Route::ClusterPage {} - | Route::PythonPage {} - | Route::ChromeTracingRedirect {} - ); - let expanded = *show_dropdown.read(); - let button_class = format!( - "w-full focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-slate-900 {}", - sidebar_item_class(is_active) - ); - - rsx! { - div { - button { - class: "{button_class}", - aria_expanded: if expanded { "true" } else { "false" }, - aria_label: "Advanced menu", - onclick: move |_| { - let current = *show_dropdown.read(); - *show_dropdown.write() = !current; - }, - Icon { icon: &icondata::AiSettingOutlined, class: "w-4 h-4" } - span { "Advanced" } - } - - if expanded { - div { - class: "ml-4 mt-0.5 space-y-0.5", - AdvancedSubLink { - to: Route::DashboardPage {}, - label: "Dashboard", - icon: &icondata::AiLineChartOutlined, - } - AdvancedSubLink { - to: Route::ClusterPage {}, - label: "Cluster", - icon: &icondata::AiClusterOutlined, - } - AdvancedSubLink { - to: Route::PythonPage {}, - label: "Python", - icon: &icondata::SiPython, - } - AdvancedSubLink { - to: Route::ChromeTracingRedirect {}, - label: "Chrome Tracing", - icon: &icondata::AiThunderboltOutlined, - } - } - } - } - } -} - -#[component] -fn AdvancedSubLink(to: Route, label: &'static str, icon: &'static IconData) -> Element { - let route = use_route::(); - let is_selected = route == to; - let link_class = format!("{}", sidebar_item_class(is_selected)); - - rsx! { - Link { - to: to, - class: "{link_class}", - Icon { icon, class: "w-4 h-4" } - span { "{label}" } - } - } -} diff --git a/web/src/components/sidebar/mod.rs b/web/src/components/sidebar/mod.rs deleted file mode 100644 index 1426afc8..00000000 --- a/web/src/components/sidebar/mod.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Sidebar: logo, nav list, Profiling submenu, footer. -//! Uses [colors](crate::components::colors). Width/visibility in [state::sidebar](crate::state::sidebar). - -use dioxus::prelude::*; -use dioxus_router::{use_route, Link}; - -use crate::app::Route; -use crate::components::colors::colors; -use crate::components::icon::Icon; -use crate::state::sidebar::{ - load_sidebar_state, save_sidebar_state, SIDEBAR_HIDDEN, SIDEBAR_WIDTH, -}; - -mod monitors; -mod nav_item; -pub(crate) mod profiling; -mod resize; -mod stack; - -use monitors::SidebarMonitors; -use nav_item::{SidebarNavItem, SidebarSectionLabel}; -use profiling::ProfilingSidebarItem; -use resize::ResizeHandle; -use stack::StackSidebarItem; - -fn sidebar_classes() -> ( - &'static str, - &'static str, - &'static str, - &'static str, - &'static str, - &'static str, -) { - ( - colors::SIDEBAR_ASIDE, - colors::SIDEBAR_LOGO_BORDER, - colors::SIDEBAR_BRAND, - colors::SIDEBAR_FOOTER, - colors::SIDEBAR_FOOTER_LINK, - colors::SIDEBAR_HIDE_BTN, - ) -} - -#[component] -pub fn Sidebar() -> Element { - let route = use_route::(); - let mut show_profiling_dropdown = use_signal(|| false); - let mut show_stack_dropdown = use_signal(|| false); - - use_effect(move || { - load_sidebar_state(); - }); - - let route_for_profiling = route.clone(); - use_effect(move || { - if matches!(route_for_profiling, Route::ProfilingViewPage { .. }) { - *show_profiling_dropdown.write() = true; - } - }); - - let route_for_stack = route.clone(); - use_effect(move || { - if matches!( - route_for_stack, - Route::StackPage {} - | Route::StackDistributedFullPage {} - | Route::StackDistributedPyPage {} - | Route::StackWithTidPage { .. } - ) { - *show_stack_dropdown.write() = true; - } - }); - - let width = *SIDEBAR_WIDTH.read(); - let (aside, logo_border, brand, footer, footer_link, hide_btn) = sidebar_classes(); - let main_style = format!("width: {}px;", width); - - rsx! { - div { - class: "relative flex h-screen", - style: "{main_style}", - aside { - class: "{aside}", - style: "{main_style}", - div { - class: "{logo_border}", - Link { - to: Route::DashboardPage {}, - class: "flex items-center gap-2", - img { src: "{crate::utils::base_path::with_base(\"/logo.svg\")}", alt: "Probing", class: "w-7 h-7 flex-shrink-0" } - span { class: "{brand}", "Probing" } - } - } - - nav { - class: "flex-1 overflow-y-auto py-3", - div { class: "px-2 space-y-0.5", - SidebarSectionLabel { label: "Overview" } - SidebarNavItem { - to: Route::DashboardPage {}, - icon: &icondata::AiLineChartOutlined, - label: "Dashboard", - is_active: route == Route::DashboardPage {}, - } - SidebarNavItem { - to: Route::AgentPage {}, - icon: &icondata::AiRobotOutlined, - label: "Investigate", - title: "Skill-driven investigation (diagnostic agent)", - is_active: route == Route::AgentPage {}, - } - StackSidebarItem { - show_dropdown: show_stack_dropdown, - } - - SidebarSectionLabel { label: "RL" } - SidebarNavItem { - to: Route::RolloutPage {}, - icon: &icondata::AiDeploymentUnitOutlined, - label: "Rollout", - is_active: route == Route::RolloutPage {}, - } - SidebarNavItem { - to: Route::TrainPage {}, - icon: &icondata::AiRadarChartOutlined, - label: "Train", - is_active: route == Route::TrainPage {}, - } - SidebarNavItem { - to: Route::RlSpansPage {}, - icon: &icondata::AiApartmentOutlined, - label: "RL Spans", - title: "Distributed RL span hierarchy with cross-process linking", - is_active: route == Route::RlSpansPage {}, - } - SidebarNavItem { - to: Route::ProcessTimelinePage {}, - icon: &icondata::AiClockCircleOutlined, - label: "Process Timeline", - is_active: route == Route::ProcessTimelinePage {}, - } - SidebarNavItem { - to: Route::PerfettoPage {}, - icon: &icondata::AiThunderboltOutlined, - label: "Perfetto", - is_active: route == Route::PerfettoPage {}, - } - SidebarNavItem { - to: Route::InferencePage {}, - icon: &icondata::AiDashboardOutlined, - label: "Inference", - title: "Inference engine Prometheus metrics", - is_active: route == Route::InferencePage {}, - } - - SidebarSectionLabel { label: "Analysis" } - ProfilingSidebarItem { - show_dropdown: show_profiling_dropdown, - } - SidebarNavItem { - to: Route::AnalyticsPage {}, - icon: &icondata::AiAreaChartOutlined, - label: "Analytics", - is_active: route == Route::AnalyticsPage {}, - } - SidebarNavItem { - to: Route::SpansPage {}, - icon: &icondata::AiApiOutlined, - label: "Spans", - title: "Hierarchical tracing spans from python.trace_event", - is_active: route == Route::SpansPage {}, - } - SidebarNavItem { - to: Route::TrainingPage {}, - icon: &icondata::AiRadarChartOutlined, - label: "Training", - is_active: route == Route::TrainingPage {}, - } - SidebarNavItem { - to: Route::PulsingPage {}, - icon: &icondata::AiDeploymentUnitOutlined, - label: "Pulsing", - is_active: route == Route::PulsingPage {}, - } - - SidebarSectionLabel { label: "System" } - SidebarNavItem { - to: Route::ClusterPage {}, - icon: &icondata::AiClusterOutlined, - label: "Cluster", - is_active: route == Route::ClusterPage {}, - } - SidebarNavItem { - to: Route::PythonPage {}, - icon: &icondata::SiPython, - label: "Python", - title: "Live variable tracing on functions (not distributed spans)", - is_active: route == Route::PythonPage {}, - } - } - } - - SidebarMonitors {} - - div { class: "{footer}", - a { - href: "https://github.com/reiase/probing", - target: "_blank", - class: "{footer_link}", - Icon { icon: &icondata::AiGithubOutlined, class: "w-4 h-4" } - span { "GitHub" } - } - } - } - - button { - class: "{hide_btn} focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-slate-900", - title: "Hide Sidebar", - aria_label: "Hide sidebar", - onclick: move |_| { - *SIDEBAR_HIDDEN.write() = true; - save_sidebar_state(); - }, - Icon { - icon: &icondata::AiMenuFoldOutlined, - class: "w-4 h-4 text-slate-300" - } - } - - ResizeHandle {} - } - } -} diff --git a/web/src/components/sidebar/monitors.rs b/web/src/components/sidebar/monitors.rs deleted file mode 100644 index 35ac5cb5..00000000 --- a/web/src/components/sidebar/monitors.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! Sidebar monitors — compact task + overhead summary rows; overlays render at app root. - -use dioxus::prelude::*; - -use crate::api::ApiClient; -use crate::components::icon::Icon; -use crate::components::overhead::{table_missing_trigger_label, OVERHEAD_POLL_MS}; -use crate::components::sidebar::nav_item::SidebarSectionLabel; -use crate::hooks::{use_api_with_options, use_page_visible, use_poll_tick_gated, ApiFetchOptions}; -use crate::overhead::{df_scalar_f64, OverheadSnapshot}; -use crate::state::overlays::{monitor_overlay_open, open_monitor_overlay, SidebarMonitor}; -use crate::state::ui_tasks::{ui_tasks_snapshot, UiTask, UiTaskStatus, UI_TASK_TICK}; -use crate::utils::error::AppError; - -fn overhead_sidebar_from( - summary: &Option>, - train_step: &Option>, -) -> crate::overhead::SidebarOverheadCopy { - match summary { - None => crate::overhead::SidebarOverheadCopy { - headline: "Torch overhead".to_string(), - performance: "Loading…".to_string(), - overhead: "—".to_string(), - muted: true, - }, - Some(Err(err)) => crate::overhead::SidebarOverheadCopy { - headline: "Torch overhead".to_string(), - performance: table_missing_trigger_label(err) - .unwrap_or_else(|| "Unavailable".to_string()), - overhead: "—".to_string(), - muted: true, - }, - Some(Ok(df)) => { - let train_ms = train_step - .as_ref() - .and_then(|r| r.as_ref().ok()) - .and_then(|df| df_scalar_f64(df, "train_step_median_ms", 0)); - OverheadSnapshot::from_summary(df) - .with_train_step_median(train_ms) - .sidebar_copy() - } - } -} - -fn task_summary_line(tasks: &[UiTask]) -> (String, bool) { - if let Some(task) = tasks.iter().find(|t| t.is_running()) { - return (task.label.clone(), true); - } - if let Some(task) = tasks.last() { - let suffix = match task.status { - UiTaskStatus::Failed => " (failed)", - UiTaskStatus::Cancelled => " (cancelled)", - _ => "", - }; - return (format!("{}{suffix}", task.label), false); - } - ("No background tasks".to_string(), false) -} - -#[component] -pub fn SidebarMonitors() -> Element { - let overlay_open = monitor_overlay_open(); - let _tick = UI_TASK_TICK.read(); - let tasks = ui_tasks_snapshot(); - let running = tasks.iter().filter(|t| t.is_running()).count(); - let (task_label, task_running) = task_summary_line(&tasks); - - let visible = use_page_visible(); - let poll = use_poll_tick_gated(OVERHEAD_POLL_MS, Some(visible)); - let refresh_tick = poll(); - let refresh_opts = ApiFetchOptions { - keep_previous_while_refreshing: true, - }; - - let summary = use_api_with_options( - move || { - let _ = refresh_tick; - async move { ApiClient::new().fetch_overhead_summary().await } - }, - refresh_opts, - ); - let train_step = use_api_with_options( - move || { - let _ = refresh_tick; - async move { ApiClient::new().fetch_overhead_train_step_median().await } - }, - refresh_opts, - ); - - let overhead = overhead_sidebar_from( - &summary.data.read().clone(), - &train_step.data.read().clone(), - ); - let oh_muted = if overhead.muted { - "text-slate-400" - } else { - "text-emerald-300" - }; - - rsx! { - div { - class: "shrink-0 border-t border-slate-700/30 px-2 py-1.5 space-y-1", - SidebarSectionLabel { label: "Monitor" } - - button { - class: "w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md border border-slate-700/50 \ - bg-slate-800/30 hover:bg-slate-800/60 hover:border-blue-700/40 transition-colors \ - text-left min-w-0", - title: "Open background tasks", - aria_expanded: if overlay_open == Some(SidebarMonitor::Tasks) { "true" } else { "false" }, - onclick: move |e| { - e.stop_propagation(); - open_monitor_overlay(SidebarMonitor::Tasks); - }, - if task_running { - span { - class: "inline-block w-2 h-2 border border-blue-400 border-t-transparent rounded-full animate-spin shrink-0" - } - } else if tasks.is_empty() { - Icon { icon: &icondata::AiUnorderedListOutlined, class: "w-3 h-3 text-slate-500 shrink-0" } - } else { - Icon { icon: &icondata::AiCheckOutlined, class: "w-3 h-3 text-slate-500 shrink-0" } - } - span { - class: "flex-1 min-w-0 text-[10px] text-slate-200 truncate font-medium", - "{task_label}" - } - if running > 0 { - span { - class: "shrink-0 text-[10px] tabular-nums text-blue-300 font-medium", - "{running}" - } - } - Icon { - icon: &icondata::AiExpandAltOutlined, - class: "w-3 h-3 text-slate-500 shrink-0" - } - } - - button { - class: "w-full flex flex-col gap-0.5 px-2 py-1.5 rounded-md border border-slate-700/50 \ - bg-slate-800/30 hover:bg-slate-800/60 hover:border-emerald-700/40 transition-colors \ - text-left min-w-0", - title: "Open TorchProbe overhead — step time & hook cost", - aria_expanded: if overlay_open == Some(SidebarMonitor::Overhead) { "true" } else { "false" }, - onclick: move |e| { - e.stop_propagation(); - open_monitor_overlay(SidebarMonitor::Overhead); - }, - div { class: "flex items-center gap-1.5 min-w-0", - Icon { - icon: &icondata::CgPerformance, - class: "w-3 h-3 text-emerald-400/90 shrink-0" - } - span { - class: "flex-1 min-w-0 text-[10px] text-slate-100 font-semibold truncate tabular-nums", - "{overhead.headline}" - } - Icon { - icon: &icondata::AiExpandAltOutlined, - class: "w-3 h-3 text-slate-500 shrink-0" - } - } - span { - class: "text-[9px] text-slate-400 truncate pl-[18px]", - "{overhead.performance}" - } - span { - class: "text-[9px] font-medium truncate pl-[18px] tabular-nums {oh_muted}", - "{overhead.overhead}" - } - } - } - } -} diff --git a/web/src/components/sidebar/nav_item.rs b/web/src/components/sidebar/nav_item.rs deleted file mode 100644 index f78503c9..00000000 --- a/web/src/components/sidebar/nav_item.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Sidebar nav link and shared class helper for active/inactive state. - -use dioxus::prelude::*; -use dioxus_router::Link; -use icondata::Icon as IconData; - -use crate::app::Route; -use crate::components::colors::colors; -use crate::components::icon::Icon; - -/// One style for all sidebar items (nav link, Profiling button, sub-items). Single source of truth. -pub fn sidebar_item_class(is_active: bool) -> &'static str { - if is_active { - colors::SIDEBAR_ITEM_ACTIVE - } else { - colors::SIDEBAR_ITEM_INACTIVE - } -} - -#[component] -pub fn SidebarSectionLabel(label: &'static str) -> Element { - rsx! { - div { - class: "px-2 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-500 select-none", - "{label}" - } - } -} - -#[component] -pub fn SidebarNavItem( - to: Route, - icon: &'static IconData, - label: &'static str, - is_active: bool, - #[props(default = "")] title: &'static str, -) -> Element { - rsx! { - Link { - to: to, - class: "{sidebar_item_class(is_active)}", - title: if title.is_empty() { "" } else { title }, - Icon { icon, class: "w-4 h-4" } - span { "{label}" } - } - } -} diff --git a/web/src/components/sidebar/profiling/mod.rs b/web/src/components/sidebar/profiling/mod.rs deleted file mode 100644 index f363e390..00000000 --- a/web/src/components/sidebar/profiling/mod.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Profiling submenu, view switcher, and embedded controls in the sidebar. - -use dioxus::prelude::*; -use dioxus_router::{use_route, Link}; -use icondata::Icon as IconData; - -use crate::app::Route; -use crate::components::colors::colors; -use crate::components::icon::Icon; -use crate::components::sidebar::nav_item::sidebar_item_class; -use crate::state::profiling::{normalize_profiling_view, profiling_view_label, PROFILING_VIEWS}; - -pub(crate) mod controls; -use controls::{ - PprofControls, PyTorchTimelineControls, RayTimelineControls, TorchControls, - TraceTimelineControls, -}; - -fn profiling_view_icon(id: &str) -> &'static IconData { - match id { - "pprof" => &icondata::CgPerformance, - "torch" => &icondata::AiFireOutlined, - "trace" => &icondata::AiThunderboltOutlined, - "pytorch" => &icondata::SiPytorch, - "ray" => &icondata::AiClockCircleOutlined, - _ => &icondata::AiSearchOutlined, - } -} - -#[component] -pub fn ProfilingSidebarItem(show_dropdown: Signal) -> Element { - let route = use_route::(); - let is_active = matches!(route, Route::ProfilingViewPage { .. }); - let expanded = *show_dropdown.read(); - let button_class = format!( - "w-full focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-slate-900 {}", - sidebar_item_class(is_active) - ); - - let current_view = match route { - Route::ProfilingViewPage { view } => normalize_profiling_view(&view).to_string(), - _ => String::new(), - }; - - rsx! { - div { - button { - class: "{button_class}", - aria_expanded: if expanded { "true" } else { "false" }, - aria_label: "Profiling menu", - title: "CPU/torch flamegraphs and chrome trace timelines", - onclick: move |_| { - let current = *show_dropdown.read(); - *show_dropdown.write() = !current; - }, - Icon { icon: &icondata::AiSearchOutlined, class: "w-4 h-4" } - span { "Profiling" } - } - - if expanded { - div { - class: "ml-4 mt-0.5 space-y-0.5", - for spec in PROFILING_VIEWS { - ProfilingSubItem { - view: spec.id.to_string(), - label: spec.sidebar_label.to_string(), - tooltip: spec.tooltip.to_string(), - icon: profiling_view_icon(spec.id), - current_view: current_view.clone(), - } - } - - if is_active { - ProfilingControlsPanel { key: "{current_view}", current_view } - } - } - } - } - } -} - -#[component] -fn ProfilingSubItem( - view: String, - label: String, - tooltip: String, - icon: &'static IconData, - current_view: String, -) -> Element { - let is_selected = current_view == view; - let button_class = format!("w-full {}", sidebar_item_class(is_selected)); - let check_class = "ml-auto text-blue-400 font-semibold"; - - rsx! { - Link { - to: Route::ProfilingViewPage { view: view.clone() }, - class: "{button_class}", - title: "{tooltip}", - Icon { icon, class: "w-4 h-4" } - span { "{label}" } - if is_selected { - span { class: "{check_class}", "✓" } - } - } - } -} - -#[component] -fn ProfilingControlsPanel(current_view: String) -> Element { - let panel_border_class = colors::SIDEBAR_PANEL_BORDER; - let control_title_class = colors::SIDEBAR_CONTROL_TITLE; - let control_value_class = colors::SIDEBAR_CONTROL_VALUE; - let toggle_enabled_class = colors::SIDEBAR_TOGGLE_ON; - let toggle_disabled_class = colors::SIDEBAR_TOGGLE_OFF; - let toggle_label_class = colors::SIDEBAR_TOGGLE_LABEL; - let input_class = colors::SIDEBAR_INPUT; - - rsx! { - div { - class: "{panel_border_class}", - div { - class: "px-1 space-y-4", - p { - class: "text-[10px] uppercase tracking-wide text-slate-500 mb-2", - "{profiling_view_label(¤t_view)} controls" - } - match current_view.as_str() { - "pprof" => rsx! { - PprofControls { - control_title_class: control_title_class, - control_value_class: control_value_class, - } - }, - "torch" => rsx! { - TorchControls { - control_title_class: control_title_class, - toggle_enabled_class: toggle_enabled_class, - toggle_disabled_class: toggle_disabled_class, - toggle_label_class: toggle_label_class, - } - }, - "trace" => rsx! { - TraceTimelineControls { - control_title_class: control_title_class, - control_value_class: control_value_class, - input_class: input_class, - } - }, - "pytorch" => rsx! { - PyTorchTimelineControls { - control_title_class: control_title_class, - input_class: input_class, - } - }, - "ray" => rsx! { - RayTimelineControls { - control_title_class: control_title_class, - } - }, - _ => rsx! { div {} }, - } - } - } - } -} diff --git a/web/src/components/sidebar/resize.rs b/web/src/components/sidebar/resize.rs deleted file mode 100644 index 6ffb4e2a..00000000 --- a/web/src/components/sidebar/resize.rs +++ /dev/null @@ -1,56 +0,0 @@ -use dioxus::prelude::*; - -use crate::components::colors::colors; -use crate::state::sidebar::{save_sidebar_state, SIDEBAR_WIDTH}; - -#[component] -pub fn ResizeHandle() -> Element { - let mut is_resizing = use_signal(|| false); - let mut drag_start_x = use_signal(|| 0.0); - let mut drag_start_width = use_signal(|| 256.0); - - let active_class = if *is_resizing.read() { - colors::SIDEBAR_RESIZE_ACTIVE - } else { - "bg-transparent" - }; - let drag_handle_class = format!( - "absolute top-0 right-0 w-1 h-full cursor-col-resize {} transition-colors group z-20 {}", - colors::SIDEBAR_RESIZE_HOVER, - active_class - ); - - rsx! { - div { - class: "{drag_handle_class}", - onmousedown: move |ev| { - *is_resizing.write() = true; - *drag_start_x.write() = ev.element_coordinates().x; - *drag_start_width.write() = *SIDEBAR_WIDTH.read(); - ev.prevent_default(); - }, - onmousemove: move |ev| { - if *is_resizing.read() { - let current_x = ev.element_coordinates().x; - let delta_x = current_x - *drag_start_x.read(); - let new_width = (*drag_start_width.read() + delta_x).clamp(200.0, 600.0); - *SIDEBAR_WIDTH.write() = new_width; - } - }, - onmouseup: move |_| { - if *is_resizing.read() { - *is_resizing.write() = false; - save_sidebar_state(); - } - }, - onmouseleave: move |_| { - if *is_resizing.read() { - *is_resizing.write() = false; - } - }, - div { - class: "absolute top-1/2 right-0 transform translate-x-1/2 -translate-y-1/2 w-1 h-8 bg-gray-300 rounded-full opacity-0 group-hover:opacity-100 transition-opacity", - } - } - } -} diff --git a/web/src/components/sidebar/stack/mod.rs b/web/src/components/sidebar/stack/mod.rs deleted file mode 100644 index 1d80c66a..00000000 --- a/web/src/components/sidebar/stack/mod.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! Stack controls in the left sidebar — collapsible like Profiling. - -use dioxus::prelude::*; -use dioxus_router::{use_route, Link}; - -use crate::app::Route; -use crate::components::colors::colors; -use crate::components::icon::Icon; -use crate::components::sidebar::nav_item::sidebar_item_class; -use crate::state::stack::{ - bump_stack_refresh, stack_tid_label, STACK_DIST_CLUSTER, STACK_DIST_RELOAD, STACK_MODE, - STACK_SNAPSHOT, -}; -use crate::utils::callframe::{mode_for_kind, FrameKind}; - -#[component] -pub fn StackSidebarItem(show_dropdown: Signal) -> Element { - let route = use_route::(); - let is_active = matches!( - route, - Route::StackPage {} - | Route::StackDistributedFullPage {} - | Route::StackDistributedPyPage {} - | Route::StackWithTidPage { .. } - ); - let expanded = *show_dropdown.read(); - let on_default = matches!(route, Route::StackPage {}); - let on_dist_full = matches!(route, Route::StackDistributedFullPage {}); - let on_dist_py = matches!(route, Route::StackDistributedPyPage {}); - let on_distributed = on_dist_full || on_dist_py; - let tid = match &route { - Route::StackWithTidPage { tid } => Some(tid.clone()), - _ => None, - }; - - let button_class = format!( - "w-full focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-slate-900 {}", - sidebar_item_class(is_active) - ); - - rsx! { - div { - button { - class: "{button_class}", - aria_expanded: if expanded { "true" } else { "false" }, - aria_label: "Stacks menu", - title: "Live call stack and distributed CPU flamegraph", - onclick: move |_| { - let current = *show_dropdown.read(); - *show_dropdown.write() = !current; - }, - Icon { icon: &icondata::AiApartmentOutlined, class: "w-4 h-4" } - span { "Stacks" } - } - - if expanded { - div { - class: "ml-4 mt-0.5 space-y-0.5", - StackSubLink { - label: "Default thread", - hint: "Process default sampling thread", - is_selected: on_default, - route: Route::StackPage {}, - } - StackSubLink { - label: "Distributed · Full stack", - hint: "Cluster flamegraph · Python + native merged across ranks", - is_selected: on_dist_full, - route: Route::StackDistributedFullPage {}, - } - StackSubLink { - label: "Distributed · Python", - hint: "Cluster flamegraph · Python frames only across ranks", - is_selected: on_dist_py, - route: Route::StackDistributedPyPage {}, - } - if let Some(tid) = tid { - StackSubLink { - label: format!("tid {tid}"), - hint: "Stack for a specific thread (from Dashboard)", - is_selected: true, - route: Route::StackWithTidPage { tid: tid.clone() }, - } - } - if is_active { - if on_distributed { - StackDistributedControlsPanel {} - } else { - StackControlsPanel {} - } - } - } - } - } - } -} - -#[component] -fn StackSubLink(label: String, hint: String, is_selected: bool, route: Route) -> Element { - let class = format!("w-full {}", sidebar_item_class(is_selected)); - rsx! { - Link { - to: route, - class: "{class}", - title: "{hint}", - span { class: "flex-1 min-w-0 truncate text-sm", "{label}" } - if is_selected { - span { class: "ml-auto text-[10px] text-blue-300/80 shrink-0", "✓" } - } - } - } -} - -#[component] -fn StackControlsPanel() -> Element { - let route = use_route::(); - let tid = match route { - Route::StackWithTidPage { tid } => Some(tid), - _ => None, - }; - let tid_label = stack_tid_label(tid.as_deref()); - let snapshot = STACK_SNAPSHOT.read().clone(); - let mode = STACK_MODE(); - let panel_border = colors::SIDEBAR_PANEL_BORDER; - let title_class = colors::SIDEBAR_CONTROL_TITLE; - let value_class = colors::SIDEBAR_CONTROL_VALUE; - - rsx! { - div { - class: "{panel_border}", - div { - class: "px-1 space-y-2.5", - p { - class: "text-[10px] uppercase tracking-wide text-slate-500", - "Stack" - } - p { - class: "{value_class} font-mono text-[11px]", - title: "Current stack target", - "{tid_label}" - } - if snapshot.loaded { - p { - class: "{value_class} text-[11px] leading-snug tabular-nums", - "{snapshot.shown}/{snapshot.total} shown · py {snapshot.py} · rust {snapshot.rust} · native {snapshot.cpp}" - } - } - div { class: "space-y-1", - p { class: "{title_class} text-[10px]", "Filter" } - div { class: "flex flex-wrap gap-1", - FilterChip { filter: "mixed", label: "All", active: mode == "mixed" } - FilterChip { - filter: mode_for_kind(FrameKind::Python), - label: "Py", - active: mode == mode_for_kind(FrameKind::Python), - } - FilterChip { - filter: mode_for_kind(FrameKind::Rust), - label: "Rs", - active: mode == mode_for_kind(FrameKind::Rust), - } - FilterChip { - filter: mode_for_kind(FrameKind::Cpp), - label: "Nat", - active: mode == mode_for_kind(FrameKind::Cpp), - } - } - } - button { - r#type: "button", - class: "w-full px-2 py-1 text-[11px] rounded-md border border-slate-600 bg-slate-800/80 text-slate-300 hover:bg-slate-700 transition-colors", - onclick: move |_| bump_stack_refresh(), - "Refresh" - } - } - } - } -} - -#[component] -fn StackDistributedControlsPanel() -> Element { - let cluster = *STACK_DIST_CLUSTER.read(); - let panel_border = colors::SIDEBAR_PANEL_BORDER; - let title_class = colors::SIDEBAR_CONTROL_TITLE; - - rsx! { - div { - class: "{panel_border}", - div { - class: "px-1 space-y-3", - p { - class: "text-[10px] uppercase tracking-wide text-slate-500", - "Distributed flamegraph" - } - p { - class: "text-[11px] text-slate-400 leading-snug", - "Merge main-thread stacks from all ranks. Requires SIGPROF sampling (Profiling → CPU) for accumulated samples." - } - label { - class: "flex items-center gap-2 cursor-pointer select-none text-xs text-slate-300", - input { - r#type: "checkbox", - checked: cluster, - onchange: move |_| { - *STACK_DIST_CLUSTER.write() = !*STACK_DIST_CLUSTER.read(); - }, - } - "Cluster fan-out" - } - button { - r#type: "button", - class: format!( - "w-full px-2 py-1.5 text-xs font-medium rounded bg-{} text-white hover:bg-{}", - colors::PRIMARY, - colors::PRIMARY_HOVER - ), - onclick: move |_| { - *STACK_DIST_RELOAD.write() += 1; - }, - "Reload flamegraph" - } - p { class: "{title_class} text-[10px] text-slate-500", "Pprof frequency lives under Profiling → CPU (pprof)." } - } - } - } -} - -#[component] -fn FilterChip(filter: &'static str, label: &'static str, active: bool) -> Element { - let class = if active { - "px-1.5 py-0.5 text-[10px] rounded border border-blue-500/50 bg-blue-600/25 text-blue-100" - } else { - "px-1.5 py-0.5 text-[10px] rounded border border-slate-600/80 text-slate-400 hover:text-slate-200 hover:border-slate-500" - }; - rsx! { - button { - r#type: "button", - class: "{class}", - onclick: move |_| { - if STACK_MODE() == filter && filter != "mixed" { - *STACK_MODE.write() = String::from("mixed"); - } else { - *STACK_MODE.write() = filter.to_string(); - } - }, - "{label}" - } - } -} diff --git a/web/src/components/span_timeline.rs b/web/src/components/span_timeline.rs index 453f1aca..21958cb0 100644 --- a/web/src/components/span_timeline.rs +++ b/web/src/components/span_timeline.rs @@ -4,7 +4,9 @@ use dioxus::prelude::*; use crate::api::SpanInfo; +#[cfg(test)] const TIMELINE_LANE_PX: f64 = 148.0; +#[cfg(test)] const MIN_BAR_PX: f64 = 3.0; /// Nanosecond window covering all spans in the current tree. @@ -51,11 +53,13 @@ impl TraceTimeWindow { (self.end_ns - self.start_ns).max(1) } + #[cfg(test)] pub fn offset_px(&self, timestamp_ns: i64) -> f64 { let pct = (timestamp_ns - self.start_ns) as f64 / self.range_ns() as f64; (pct.clamp(0.0, 1.0) * TIMELINE_LANE_PX).max(0.0) } + #[cfg(test)] pub fn width_px(&self, start_ns: i64, end_ns: Option) -> f64 { let end = end_ns.unwrap_or(self.end_ns); let dur = (end - start_ns).max(0) as f64; @@ -118,6 +122,7 @@ pub fn span_total_ns(span: &SpanInfo, window: TraceTimeWindow) -> i64 { } /// Percentage of the visible trace window covered by this span. +#[cfg(test)] pub fn span_cover_pct(span: &SpanInfo, window: TraceTimeWindow) -> f64 { span_total_ns(span, window) as f64 / window.range_ns() as f64 * 100.0 } @@ -155,66 +160,6 @@ fn span_tooltip(span: &SpanInfo, window: TraceTimeWindow) -> String { ) } -#[component] -pub fn SpanTimelineHeader(window: TraceTimeWindow) -> Element { - let total = format_axis_label(window.range_ns() as f64); - let mid = format_axis_label(window.range_ns() as f64 / 2.0); - rsx! { - div { - class: "flex shrink-0 border-b border-gray-200 bg-gray-50/90 sticky top-0 z-10", - div { - class: "shrink-0 border-r border-gray-200 px-2 py-1.5", - style: "width: {TIMELINE_LANE_PX}px", - div { class: "text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1", - "Timeline" - } - div { class: "relative h-4 text-xs text-gray-500 font-mono tabular-nums", - span { class: "absolute left-0 top-0", "0" } - span { class: "absolute left-1/2 -translate-x-1/2 top-0", "{mid}" } - span { class: "absolute right-0 top-0", "{total}" } - div { class: "absolute inset-x-0 top-[14px] h-px bg-gray-200" } - div { class: "absolute left-0 top-[11px] w-px h-[7px] bg-gray-300" } - div { class: "absolute left-1/2 top-[11px] w-px h-[7px] bg-gray-300" } - div { class: "absolute right-0 top-[11px] w-px h-[7px] bg-gray-300" } - } - } - div { class: "flex-1 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500", - "Span tree" - } - } - } -} - -#[component] -pub fn SpanTimelineLegend() -> Element { - rsx! { - div { - class: "flex flex-wrap items-center gap-x-4 gap-y-1 px-2 py-1.5 border-b border-gray-100 bg-white text-xs text-gray-500 sticky top-[52px] z-10", - span { class: "font-medium text-gray-600", "Lane" } - div { class: "inline-flex items-center gap-1", - span { class: "w-3 h-2 rounded-sm bg-blue-500", aria_hidden: "true" } - span { "forward" } - } - div { class: "inline-flex items-center gap-1", - span { class: "w-3 h-2 rounded-sm bg-purple-500", aria_hidden: "true" } - span { "backward" } - } - div { class: "inline-flex items-center gap-1", - span { class: "w-3 h-2 rounded-sm bg-amber-500", aria_hidden: "true" } - span { "optimizer" } - } - div { class: "inline-flex items-center gap-1", - span { class: "w-3 h-2 rounded-sm bg-emerald-500", aria_hidden: "true" } - span { "other" } - } - div { class: "inline-flex items-center gap-1", - span { class: "w-3 h-2 rounded-sm bg-amber-500 animate-pulse motion-reduce:animate-none", aria_hidden: "true" } - span { "active" } - } - } - } -} - #[component] pub fn SpanSummaryHeader(window: TraceTimeWindow) -> Element { let total = format_axis_label(window.range_ns() as f64); @@ -296,73 +241,6 @@ pub fn SpanSummaryBar(span: SpanInfo, window: TraceTimeWindow) -> Element { } } -#[component] -pub fn SpanSummarySpacer() -> Element { - rsx! { div { class: "w-[38%] min-w-[320px] shrink-0" } } -} - -#[component] -pub fn SpanTimelineBar(span: SpanInfo, window: TraceTimeWindow, depth: usize) -> Element { - let active = span.end_timestamp.is_none(); - let left = window.offset_px(span.start_timestamp); - let width = window.width_px(span.start_timestamp, span.end_timestamp); - let (track_bg, bar_bg) = span_bar_style(span.phase.as_deref(), active); - let indent = depth * 10; - let tooltip = span_tooltip(&span, window); - let lane_inner = TIMELINE_LANE_PX - indent as f64; - let bar_left = left.min(lane_inner - MIN_BAR_PX).max(0.0); - let bar_width = width.min(lane_inner - bar_left); - let guide_left = indent.saturating_sub(6); - - rsx! { - div { - class: "shrink-0 border-r border-gray-100 flex items-center py-0.5 relative", - style: "width: {TIMELINE_LANE_PX}px; padding-left: {indent}px", - role: "img", - aria_label: "{tooltip}", - title: "{tooltip}", - if depth > 0 { - div { - class: "absolute top-0 bottom-1/2 w-px bg-gray-200", - style: "left: {guide_left}px", - } - div { - class: "absolute top-1/2 w-2 h-px bg-gray-200", - style: "left: {guide_left}px", - } - } - div { class: "relative h-[22px] flex-1 min-w-0 pr-1", - div { class: "absolute inset-y-[7px] inset-x-0 rounded-full {track_bg}" } - div { - class: "absolute top-[5px] h-[12px] rounded-sm {bar_bg} shadow-sm", - style: "left: {bar_left:.2}px; width: {bar_width:.2}px;", - } - div { - class: "absolute top-[9px] w-1.5 h-1.5 rounded-full {bar_bg} ring-2 ring-white -translate-x-1/2", - style: "left: {bar_left:.2}px;", - } - if active { - div { - class: "absolute top-[10px] h-0.5 bg-amber-400/60 animate-pulse motion-reduce:animate-none", - style: "left: calc({bar_left:.2}px + {bar_width:.2}px); right: 0;", - } - } - } - } - } -} - -/// Empty lane cell for detail rows (attributes / events) that have no bar. -#[component] -pub fn SpanTimelineSpacer() -> Element { - rsx! { - div { - class: "shrink-0 border-r border-gray-100 bg-gray-50/30", - style: "width: {TIMELINE_LANE_PX}px", - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/web/src/components/workspace/mod.rs b/web/src/components/workspace/mod.rs index 4e610b39..e05f7849 100644 --- a/web/src/components/workspace/mod.rs +++ b/web/src/components/workspace/mod.rs @@ -1,11 +1,6 @@ -//! Shared workspace UI: cards, panel shell, split layout. +//! Shared workspace UI: cards, split layout. -mod panel_shell; mod split; mod surface; -pub use panel_shell::WorkspacePanelShell; -pub use surface::{ - AccentSurface, ChipButton, StatusBadge, SurfaceCard, SurfaceCardBody, SurfaceIconHeader, - WidthSegment, -}; +pub use surface::{AccentSurface, ChipButton, StatusBadge, SurfaceCardBody, SurfaceIconHeader}; diff --git a/web/src/components/workspace/panel_shell.rs b/web/src/components/workspace/panel_shell.rs deleted file mode 100644 index c26b8251..00000000 --- a/web/src/components/workspace/panel_shell.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Right-side / auxiliary panel shell (header, toolbar, scroll body, footer). - -use dioxus::prelude::*; - -use crate::components::workspace::surface::SurfaceIconHeader; - -#[component] -pub fn WorkspacePanelShell( - title: String, - subtitle: Option, - icon: &'static icondata::Icon, - #[props(optional)] header_actions: Option, - #[props(optional)] toolbar: Option, - footer: Element, - children: Element, - #[props(default = false)] embedded: bool, -) -> Element { - let shell_class = if embedded { - "h-full min-h-0 flex flex-col rounded-lg border border-gray-200 bg-white shadow-sm" - } else { - "h-full min-h-0 flex flex-col border-l border-gray-200 bg-white" - }; - rsx! { - aside { - class: "{shell_class}", - SurfaceIconHeader { - icon: icon, - icon_class: "w-5 h-5 text-blue-600", - title: title, - subtitle: subtitle, - header_right: header_actions, - } - if let Some(bar) = toolbar { - div { class: "px-3 py-2 border-b border-gray-100 shrink-0", {bar} } - } - div { - class: "flex-1 overflow-y-auto px-3 py-3 space-y-3 min-h-0", - {children} - } - div { class: "shrink-0 p-3 border-t border-gray-200 bg-gray-50/80", {footer} } - } - } -} diff --git a/web/src/components/workspace/surface.rs b/web/src/components/workspace/surface.rs index e67e1148..664ee552 100644 --- a/web/src/components/workspace/surface.rs +++ b/web/src/components/workspace/surface.rs @@ -7,13 +7,6 @@ use crate::components::icon::Icon; pub const SURFACE_BASE: &str = "rounded-lg border border-gray-200 bg-white shadow-sm overflow-hidden"; -#[component] -pub fn SurfaceCard(children: Element) -> Element { - rsx! { - div { class: "{SURFACE_BASE}", {children} } - } -} - #[component] pub fn AccentSurface(accent: &'static str, children: Element) -> Element { rsx! { @@ -94,29 +87,6 @@ pub fn ChipButton( } } -/// Segmented width control (e.g. panel ⅓ / ⅔). -#[component] -pub fn WidthSegment( - label: &'static str, - selected: bool, - title: &'static str, - onclick: EventHandler<()>, -) -> Element { - let class = if selected { - "px-2 py-1 text-[10px] font-semibold rounded-md bg-white text-blue-700 shadow-sm border border-gray-200" - } else { - "px-2 py-1 text-[10px] font-medium rounded-md text-gray-500 hover:text-gray-800" - }; - rsx! { - button { - class: "{class}", - title: "{title}", - onclick: move |_| onclick.call(()), - "{label}" - } - } -} - #[component] pub fn SurfaceIconHeader( icon: &'static icondata::Icon, diff --git a/web/src/main.rs b/web/src/main.rs index d34c3ef9..e3ea9980 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -4,28 +4,24 @@ use dioxus::prelude::*; mod agent; mod api; -mod app; mod components; mod hooks; mod next; mod overhead; -mod pages; -mod rl_contract; mod state; -mod ui_version; mod utils; -use ui_version::RootApp; +use next::NextApp; use utils::base_path::base_path; fn main() { let base = base_path(); if base.is_empty() { - launch(RootApp); + launch(NextApp); } else { let prefix = Some(base); let config = dioxus_web::Config::new() .history(std::rc::Rc::new(dioxus_web::WebHistory::new(prefix, true))); - dioxus_web::launch::launch_cfg(RootApp, config); + dioxus_web::launch::launch_cfg(NextApp, config); } } diff --git a/web/src/next/capabilities.rs b/web/src/next/capabilities.rs index dd91547f..b50c0f41 100644 --- a/web/src/next/capabilities.rs +++ b/web/src/next/capabilities.rs @@ -98,17 +98,6 @@ async fn refresh_capability_catalog() { }; } -/// Async guard for snapshot code that can also run outside the Next shell. -pub async fn capability_available(schema: &str, table: &str, required: &[&str]) -> bool { - if matches!( - &*CAPABILITY_CATALOG.read(), - CapabilityCatalogState::Checking - ) { - refresh_capability_catalog().await; - } - capability_status(schema, table, required).allows_query() -} - #[component] pub fn CapabilityCatalogPoller() -> Element { let tick = use_poll_tick_gated(REFRESH_MS, None); diff --git a/web/src/next/components.rs b/web/src/next/components.rs index b0fbd534..cac3698f 100644 --- a/web/src/next/components.rs +++ b/web/src/next/components.rs @@ -4,7 +4,7 @@ use dioxus_router::use_route; use crate::components::icon::Icon; use crate::state::investigation::{clear_investigation_context, INVESTIGATION_CONTEXT}; use crate::state::investigation_url::context_to_search; -use crate::ui_version::{href_for, UiVersion}; +use crate::utils::base_path::with_base; use super::routes::NextRoute; @@ -244,8 +244,8 @@ pub fn evidence_href( route: &NextRoute, context: &crate::state::investigation::InvestigationContext, ) -> String { - let path = route.to_string(); - append_evidence_context(href_for(&path, UiVersion::Next), context) + let path = with_base(&route.to_string()); + append_evidence_context(path, context) } fn append_evidence_context( @@ -253,10 +253,15 @@ fn append_evidence_context( context: &crate::state::investigation::InvestigationContext, ) -> String { let query = context_to_search(context); - if !query.is_empty() { + if query.is_empty() { + return href; + } + if href.contains('?') { href.push('&'); - href.push_str(&query); + } else { + href.push('?'); } + href.push_str(&query); href } @@ -454,21 +459,6 @@ pub fn UnavailablePanel(label: String, detail: String) -> Element { } } -#[component] -pub fn ClassicLink(path: String, label: String) -> Element { - let href = href_for(&path, UiVersion::Classic); - rsx! { - a { - href, - class: "inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white \ - px-3 py-2 text-xs font-medium text-gray-700 shadow-sm hover:bg-gray-50 \ - focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2", - "{label}" - span { aria_hidden: "true", "↗" } - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -482,12 +472,13 @@ mod tests { local_step: Some(31), ..Default::default() }; - let href = append_evidence_context("/memory?ui=next".to_string(), &context); + let href = append_evidence_context("/memory".to_string(), &context); - assert!(href.contains("/memory?ui=next")); + assert!(href.starts_with("/memory?")); assert!(href.contains("rank=7")); assert!(href.contains("gpu=3")); assert!(href.contains("step=31")); + assert!(!href.contains("ui=")); } #[test] diff --git a/web/src/next/pages/explore.rs b/web/src/next/pages/explore.rs index d4ccbaf7..6823a8da 100644 --- a/web/src/next/pages/explore.rs +++ b/web/src/next/pages/explore.rs @@ -4,7 +4,7 @@ use crate::state::commands::COMMAND_PANEL_OPEN; use crate::state::investigation::INVESTIGATION_CONTEXT; use super::super::components::{ - evidence_href, ActionButton, ClassicLink, FilterInput, SectionCard, WorkspacePage, + evidence_href, ActionButton, FilterInput, SectionCard, WorkspacePage, }; use super::super::routes::NextRoute; @@ -135,14 +135,11 @@ pub fn ClassicFallbackPage(segments: Vec) -> Element { div { class: "mx-auto max-w-2xl py-12", WorkspacePage { title: "Route not found".to_string(), - subtitle: format!("{path} is not a registered Next route."), + subtitle: format!("{path} is not a registered product route."), SectionCard { - title: "Compatibility route".to_string(), - subtitle: Some("The same path can still be opened in the frozen Classic interface.".to_string()), - div { class: "flex flex-wrap gap-2", - a { href: explore_href, class: "inline-flex rounded-lg border border-gray-300 bg-white px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-50", "Search Next workspaces" } - ClassicLink { path, label: "Open Classic path".to_string() } - } + title: "Available workspaces".to_string(), + subtitle: Some("Open the capability catalog to find the matching evidence surface.".to_string()), + a { href: explore_href, class: "inline-flex rounded-lg border border-gray-300 bg-white px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-50", "Browse workspaces" } } } } diff --git a/web/src/next/pages/investigate.rs b/web/src/next/pages/investigate.rs index 5b5fd720..0d845a42 100644 --- a/web/src/next/pages/investigate.rs +++ b/web/src/next/pages/investigate.rs @@ -8,8 +8,8 @@ use crate::components::dataframe_view::DataFrameView; use crate::components::markdown_view::MarkdownView; use crate::components::source_viewer::SourceRefChip; use crate::state::agent::{ - clear_agent_messages, AgentMessage, AgentMessageKind, AgentStepStatus, AGENT_INPUT, - AGENT_MESSAGES, + clear_agent_messages, take_agent_pending_action, AgentMessage, AgentMessageKind, + AgentPendingAction, AgentStepStatus, AGENT_ACTION_TICK, AGENT_INPUT, AGENT_MESSAGES, }; use crate::state::llm_config::LLM_SETTINGS_OPEN; use crate::state::ui_tasks::{ui_agent_busy, UI_TASK_TICK}; @@ -50,6 +50,17 @@ pub(crate) fn InvestigateSession(compact: bool) -> Element { let input = AGENT_INPUT.read().clone(); let _task_tick = UI_TASK_TICK.read(); let busy = ui_agent_busy(); + + use_effect(move || { + let _ = *AGENT_ACTION_TICK.read(); + if let Some(action) = take_agent_pending_action() { + match action { + AgentPendingAction::SubmitText(text) => submit_agent_text(text), + AgentPendingAction::RunSkill(skill_id) => trigger_skill(skill_id), + } + } + }); + let skill_store = use_resource(|| async move { if skill_store_loaded() { Ok(()) diff --git a/web/src/next/shell.rs b/web/src/next/shell.rs index 1cd99825..7704e659 100644 --- a/web/src/next/shell.rs +++ b/web/src/next/shell.rs @@ -3,7 +3,7 @@ use dioxus_router::{use_route, Outlet}; use crate::api::ApiClient; use crate::components::agent::LlmSettingsOverlay; -use crate::components::global_command_panel::GlobalCommandPanel; +use crate::components::global_command_panel::{FloatingResultToast, GlobalCommandPanel}; use crate::components::icon::Icon; use crate::components::keyboard_shortcuts::{GlobalShortcutInstaller, ShortcutsHelpOverlay}; use crate::components::ui_task_runtime::UiTaskRuntime; @@ -96,6 +96,7 @@ pub fn NextShell() -> Element { if *COMMAND_PANEL_OPEN.read() { GlobalCommandPanel {} } + FloatingResultToast {} ShortcutsHelpOverlay {} LlmSettingsOverlay {} div { class: "flex h-screen overflow-hidden bg-gray-50 text-gray-950", diff --git a/web/src/next/sidebar.rs b/web/src/next/sidebar.rs index 9d38fd49..4c77eb62 100644 --- a/web/src/next/sidebar.rs +++ b/web/src/next/sidebar.rs @@ -4,7 +4,7 @@ use dioxus::prelude::*; use crate::api::ApiClient; use crate::components::icon::Icon; -use crate::components::sidebar::profiling::controls::{ +use crate::components::profiling_controls::{ PprofControls, PyTorchTimelineControls, RayTimelineControls, TorchControls, TraceTimelineControls, }; @@ -26,7 +26,6 @@ use crate::state::training::{ TRAINING_REFRESH, }; use crate::state::ui_tasks::running_ui_task_count; -use crate::ui_version::{activate, UiVersion}; use crate::utils::callframe::{mode_for_kind, FrameKind}; use super::components::evidence_href; @@ -142,7 +141,6 @@ fn SidebarRail( div { class: "shrink-0 space-y-1 border-t border-slate-800 p-2", RailAction { label: format!("Tasks · {task_count}"), icon: &icondata::AiUnorderedListOutlined, onclick: move |_| open_monitor_overlay(SidebarMonitor::Tasks) } RailAction { label: "Overhead".to_string(), icon: &icondata::AiDashboardOutlined, onclick: move |_| open_monitor_overlay(SidebarMonitor::Overhead) } - RailAction { label: "Classic interface".to_string(), icon: &icondata::AiSwapOutlined, onclick: move |_| activate(UiVersion::Classic) } RailAction { label: "Expand sidebar".to_string(), icon: &icondata::AiMenuUnfoldOutlined, onclick: move |_| on_toggle_compact.call(()) } } } @@ -918,14 +916,6 @@ fn SidebarFooter(task_count: usize) -> Element { onclick: move |_| open_monitor_overlay(SidebarMonitor::Overhead), Icon { icon: &icondata::AiDashboardOutlined, class: "h-3.5 w-3.5" } } - button { - r#type: "button", - class: "rounded-md p-1.5 text-slate-400 hover:bg-slate-900 hover:text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400", - title: "Switch to Classic interface", - aria_label: "Switch to Classic interface", - onclick: move |_| activate(UiVersion::Classic), - Icon { icon: &icondata::AiSwapOutlined, class: "h-3.5 w-3.5" } - } } } } diff --git a/web/src/overhead/metrics.rs b/web/src/overhead/metrics.rs index cd407825..40951419 100644 --- a/web/src/overhead/metrics.rs +++ b/web/src/overhead/metrics.rs @@ -58,6 +58,7 @@ impl OverheadLevel { } } + #[cfg(test)] pub fn label(self) -> &'static str { match self { Self::Low => "Within normal range", @@ -67,12 +68,14 @@ impl OverheadLevel { } } + #[cfg(test)] pub fn is_reassuring(self) -> bool { matches!(self, Self::Low) } } /// Compact copy for the left sidebar monitor row. +#[cfg(test)] #[derive(Clone, Debug, PartialEq)] pub struct SidebarOverheadCopy { pub headline: String, @@ -185,6 +188,7 @@ impl OverheadSnapshot { OverheadLevel::from_pct(self.dispatch_overhead_pct) } + #[cfg(test)] pub fn sidebar_copy(&self) -> SidebarOverheadCopy { if !self.shadow_enabled() { return SidebarOverheadCopy { @@ -328,10 +332,6 @@ pub fn table_missing_message(err: &AppError) -> Option<&'static str> { } } -pub fn table_missing_trigger_label(err: &AppError) -> Option { - table_missing_message(err).map(|_| "Profiler off".to_string()) -} - pub fn parse_overhead_steps(df: &DataFrame) -> Vec { let mut steps = Vec::new(); let rows = df.row_count(); diff --git a/web/src/overhead/mod.rs b/web/src/overhead/mod.rs index cca37558..6a2eac26 100644 --- a/web/src/overhead/mod.rs +++ b/web/src/overhead/mod.rs @@ -5,6 +5,6 @@ pub mod sql; pub use metrics::{ dataframe_rows, df_scalar_f64, df_scalar_i64, format_pct_display, format_pct_signed, - format_step_ms, parse_overhead_steps, table_missing_message, table_missing_trigger_label, - OverheadLevel, OverheadSnapshot, OverheadStep, SidebarOverheadCopy, + format_step_ms, parse_overhead_steps, table_missing_message, OverheadLevel, OverheadSnapshot, + OverheadStep, }; diff --git a/web/src/pages/agent.rs b/web/src/pages/agent.rs deleted file mode 100644 index b88961e6..00000000 --- a/web/src/pages/agent.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! Investigation Agent — full-page workspace view. - -use dioxus::prelude::*; - -use crate::components::agent::chat::{AgentChat, AgentChatVariant}; -use crate::components::page::PageContainer; -use crate::state::agent::AGENT_PANEL_OPEN; - -#[component] -pub fn Agent() -> Element { - use_effect(move || { - // Dedicated page replaces the floating overlay. - *AGENT_PANEL_OPEN.write() = false; - }); - - rsx! { - PageContainer { - div { class: "flex flex-col min-h-[calc(100vh-11rem)]", - AgentChat { variant: AgentChatVariant::Page } - } - } - } -} diff --git a/web/src/pages/analytics.rs b/web/src/pages/analytics.rs deleted file mode 100644 index f2abd09f..00000000 --- a/web/src/pages/analytics.rs +++ /dev/null @@ -1,599 +0,0 @@ -use dioxus::html::events::KeyboardEvent; -use dioxus::html::input_data::keyboard_types::Key; -use dioxus::prelude::*; - -use crate::api::ApiClient; -use crate::components::card::Card; -use crate::components::colors::colors; -use crate::components::common::{query_result, AppErrorDisplay, AsyncBoundary, LoadingState}; -use crate::components::dataframe_view::DataFrameView; -use crate::components::icon::Icon; -use crate::components::page::{PageContainer, PageTitle}; -use crate::hooks::use_app_resource; -use crate::utils::error::AppError; -use probing_proto::prelude::{DataFrame, Ele}; - -const HIDDEN_SCHEMAS: &[&str] = &["information_schema"]; - -#[derive(Clone, PartialEq, Eq)] -struct TableEntry { - schema: String, - table: String, -} - -impl TableEntry { - fn fqtn(&self, global_mode: bool) -> String { - if global_mode { - format!("global.{}.{}", self.schema, self.table) - } else { - format!("{}.{}", self.schema, self.table) - } - } - - fn preview_sql(&self, global_mode: bool) -> String { - format!("SELECT * FROM {} LIMIT 10", self.fqtn(global_mode)) - } -} - -fn catalog_sql(global_mode: bool) -> String { - if global_mode { - "SELECT table_schema, table_name FROM information_schema.tables \ - WHERE table_catalog = 'global' \ - ORDER BY table_schema, table_name" - .to_string() - } else { - format!( - "SELECT table_schema, table_name FROM information_schema.tables \ - WHERE table_catalog = 'probe' AND table_schema NOT IN ({}) \ - ORDER BY table_schema, table_name", - HIDDEN_SCHEMAS - .iter() - .map(|s| format!("'{s}'")) - .collect::>() - .join(", ") - ) - } -} - -fn example_sql(global_mode: bool) -> &'static str { - if global_mode { - "SELECT * FROM global.python.comm_collective LIMIT 10" - } else { - "SELECT * FROM python.backtrace LIMIT 10" - } -} - -#[component] -pub fn Analytics() -> Element { - let global_mode = use_signal(|| false); - let mut sql = use_signal(String::new); - let mut selected_table = use_signal(|| None::); - let mut preview_title = use_signal(String::new); - let mut preview_open = use_signal(|| false); - let mut preview = - use_action( - |fqtn: String| async move { ApiClient::new().execute_preview_last10(&fqtn).await }, - ); - - let select_for_query = move |entry: TableEntry| { - let global = global_mode(); - let fqtn = entry.fqtn(global); - selected_table.set(Some(fqtn.clone())); - sql.set(entry.preview_sql(global)); - }; - - let open_preview = move |entry: TableEntry| { - let global = global_mode(); - let fqtn = entry.fqtn(global); - preview_title.set(format!("{fqtn} • latest 10 rows")); - preview_open.set(true); - preview.call(fqtn); - }; - - rsx! { - PageContainer { - PageTitle { - title: "Analytics".to_string(), - subtitle: Some("Browse tables, compose SQL, and inspect results".to_string()), - icon: Some(&icondata::AiAreaChartOutlined), - } - - div { class: "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start", - div { class: "lg:col-span-4 min-w-0", - Card { - title: "Catalog", - content_class: Some("p-0"), - header_right: Some(rsx! { - GlobalModeToggle { - global_mode, - on_change: move |_| { - selected_table.set(None); - }, - } - }), - AsyncBoundary { - message: Some("Loading tables...".to_string()), - TableCatalog { - global_mode, - selected_table, - on_select: select_for_query, - on_preview: open_preview, - } - } - } - } - - div { class: "lg:col-span-8 min-w-0", - Card { - title: "SQL Editor", - content_class: Some("p-4"), - SqlEditorPanel { - global_mode, - sql, - selected_table, - on_clear_selection: move |_| selected_table.set(None), - } - } - } - } - - if *preview_open.read() { - PreviewModal { - title: preview_title(), - preview, - on_close: move |_| preview_open.set(false), - on_use_in_editor: move |query: String| { - sql.set(query); - preview_open.set(false); - }, - } - } - } - } -} - -fn column_index(df: &DataFrame, candidates: &[&str]) -> Option { - candidates.iter().find_map(|name| { - df.names - .iter() - .position(|col| col.eq_ignore_ascii_case(name)) - }) -} - -fn parse_tables(df: &DataFrame) -> Vec { - let schema_idx = column_index(df, &["table_schema", "schema"]).unwrap_or(0); - let table_idx = column_index(df, &["table_name", "table"]).unwrap_or(1); - let nrows = df.cols.first().map(|c| c.len()).unwrap_or(0); - - (0..nrows) - .filter_map(|row| { - let schema = match df.cols.get(schema_idx).map(|c| c.get(row)) { - Some(Ele::Text(name)) => name.to_string(), - _ => return None, - }; - let table = match df.cols.get(table_idx).map(|c| c.get(row)) { - Some(Ele::Text(name)) => name.to_string(), - _ => return None, - }; - if HIDDEN_SCHEMAS - .iter() - .any(|hidden| hidden.eq_ignore_ascii_case(&schema)) - { - return None; - } - Some(TableEntry { schema, table }) - }) - .collect() -} - -fn filter_tables(entries: &[TableEntry], query: &str, global_mode: bool) -> Vec { - let q = query.trim().to_lowercase(); - if q.is_empty() { - return entries.to_vec(); - } - entries - .iter() - .filter(|e| { - e.schema.to_lowercase().contains(&q) - || e.table.to_lowercase().contains(&q) - || e.fqtn(global_mode).to_lowercase().contains(&q) - }) - .cloned() - .collect() -} - -#[component] -fn GlobalModeToggle(mut global_mode: Signal, on_change: EventHandler<()>) -> Element { - let active = global_mode(); - rsx! { - div { - class: "inline-flex items-center rounded-lg border border-gray-200 bg-gray-50 p-0.5 text-xs", - title: "Local probe tables vs cluster-wide global.* federation", - button { - class: if !active { - "px-2.5 py-1 rounded-md bg-white shadow-sm font-medium text-gray-800" - } else { - "px-2.5 py-1 rounded-md font-medium text-gray-500 hover:text-gray-700" - }, - onclick: move |_| { - if global_mode() { - global_mode.set(false); - on_change.call(()); - } - }, - "Local" - } - button { - class: if active { - "px-2.5 py-1 rounded-md bg-violet-100 shadow-sm font-medium text-violet-800" - } else { - "px-2.5 py-1 rounded-md font-medium text-gray-500 hover:text-gray-700" - }, - onclick: move |_| { - if !global_mode() { - global_mode.set(true); - on_change.call(()); - } - }, - "global.*" - } - } - } -} - -#[component] -fn TableCatalog( - global_mode: Signal, - selected_table: Signal>, - on_select: EventHandler, - on_preview: EventHandler, -) -> Element { - let mut filter = use_signal(String::new); - let tables = use_app_resource(move || { - let query = catalog_sql(global_mode()); - async move { ApiClient::new().execute_query(&query).await } - }); - let df = tables.suspend()?(); - let global = global_mode(); - - query_result( - df, - |df| df.cols.is_empty(), - if global { - "No global tables found." - } else { - "No tables found." - }, - move |df| { - let all = parse_tables(&df); - let filtered = filter_tables(&all, &filter(), global); - rsx! { - div { class: "border-b border-gray-200 px-3 py-2.5 bg-gray-50/80", - div { class: "relative", - span { class: "absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none", - Icon { icon: &icondata::AiSearchOutlined, class: "w-4 h-4" } - } - input { - r#type: "text", - class: "w-full pl-8 pr-3 py-2 text-sm rounded-md border border-gray-300 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500", - placeholder: "Filter schema or table…", - value: "{filter}", - oninput: move |ev| filter.set(ev.value()), - } - } - p { class: "mt-1.5 text-xs text-gray-500", - "{filtered.len()} of {all.len()} tables · click to load query · " - if global { - span { class: "text-violet-700", "cluster fan-out via global.*" } - } else { - span { class: "text-gray-400", "eye icon to preview" } - } - } - } - if filtered.is_empty() { - div { class: "px-4 py-10 text-center text-sm text-gray-500", - "No tables match \"{filter}\"" - } - } else { - div { class: "max-h-[min(28rem,50vh)] overflow-y-auto divide-y divide-gray-100", - for entry in filtered { - { - let fqtn = entry.fqtn(global); - let is_selected = selected_table.read().as_deref() == Some(fqtn.as_str()); - let entry_select = entry.clone(); - let entry_preview = entry.clone(); - let schema_badge_class = if global { - "shrink-0 text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border bg-violet-50 text-violet-800 border-violet-200".to_string() - } else { - format!( - "shrink-0 text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border bg-{} text-{} border-{}", - colors::CONTENT_ACCENT_BG, - colors::CONTENT_ACCENT_TEXT, - colors::CONTENT_ACCENT_BORDER, - ) - }; - rsx! { - div { - class: if is_selected { - if global { - "flex items-center gap-2 px-3 py-2.5 bg-violet-50/80" - } else { - "flex items-center gap-2 px-3 py-2.5 bg-blue-50/80" - } - } else { - "flex items-center gap-2 px-3 py-2.5 hover:bg-gray-50 transition-colors" - }, - button { - class: "flex-1 min-w-0 text-left", - onclick: move |_| on_select.call(entry_select.clone()), - div { class: "flex items-center gap-2 min-w-0", - if global { - span { class: "shrink-0 font-mono text-[10px] text-violet-700", "global" } - } - span { - class: "{schema_badge_class}", - "{entry.schema}" - } - span { class: "font-mono text-sm text-gray-900 truncate", "{entry.table}" } - } - } - button { - class: "shrink-0 p-1.5 rounded-md text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors", - title: "Preview latest 10 rows", - onclick: move |_| on_preview.call(entry_preview.clone()), - Icon { icon: &icondata::AiEyeOutlined, class: "w-4 h-4" } - } - } - } - } - } - } - } - } - }, - ) -} - -#[component] -fn SqlEditorPanel( - global_mode: Signal, - sql: Signal, - selected_table: Signal>, - on_clear_selection: EventHandler<()>, -) -> Element { - let mut run_query = use_action(|query: String| async move { - if query.trim().is_empty() { - return Err(AppError::Api("SQL query cannot be empty".to_string())); - } - ApiClient::new().execute_query(&query).await - }); - let global = global_mode(); - let placeholder = if global { - "SELECT * FROM global.schema.table LIMIT 10" - } else { - "SELECT * FROM schema.table LIMIT 10" - }; - - rsx! { - div { class: "space-y-4", - if global { - div { - class: "text-xs px-3 py-2 rounded-md border border-violet-200 bg-violet-50 text-violet-900", - "Cluster mode: SQL runs against " - span { class: "font-mono font-medium", "global.*" } - " tables (fan-out across nodes). Use " - span { class: "font-mono", "_addr" - } - " / " - span { class: "font-mono", "_rank" } - " columns to see node identity when present." - } - } - if let Some(ref fqtn) = *selected_table.read() { - div { - class: format!( - "flex flex-wrap items-center gap-2 text-xs px-3 py-2 rounded-md border bg-{} border-{} text-{}", - colors::CONTENT_ACCENT_BG, - colors::CONTENT_ACCENT_BORDER, - colors::CONTENT_ACCENT_TEXT, - ), - span { class: "font-medium", "Selected:" } - span { class: "font-mono", "{fqtn}" } - button { - class: "ml-auto text-gray-500 hover:text-gray-800 underline-offset-2 hover:underline", - onclick: move |_| on_clear_selection.call(()), - "Clear" - } - } - } - - div { class: "flex flex-wrap items-center gap-2", - button { - class: format!( - "inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white rounded-md bg-{} hover:bg-{} shadow-sm transition-colors {}", - colors::PRIMARY, - colors::PRIMARY_HOVER, - if run_query.pending() { "opacity-60 cursor-not-allowed" } else { "" } - ), - disabled: run_query.pending(), - onclick: move |_| { - if !run_query.pending() { - run_query.call(sql()); - } - }, - Icon { icon: &icondata::AiPlayCircleOutlined, class: "w-4 h-4" } - if run_query.pending() { "Running…" } else { "Run" } - } - button { - class: format!( - "px-3 py-2 text-sm rounded-md border border-gray-300 bg-white text-gray-700 hover:bg-{} transition-colors", - colors::BTN_SECONDARY_HOVER, - ), - onclick: move |_| sql.set(String::new()), - "Clear" - } - button { - class: format!( - "px-3 py-2 text-sm rounded-md border border-gray-300 bg-white text-gray-700 hover:bg-{} transition-colors", - colors::BTN_SECONDARY_HOVER, - ), - onclick: move |_| sql.set(example_sql(global_mode()).to_string()), - "Example" - } - span { class: "ml-auto text-xs text-gray-400 hidden sm:inline", - "⌘ Enter / Ctrl+Enter to run" - } - } - - div { class: "rounded-lg border border-gray-300 overflow-hidden focus-within:ring-2 focus-within:ring-blue-500/30 focus-within:border-blue-500", - textarea { - class: "w-full min-h-[140px] max-h-[320px] font-mono text-sm p-3 bg-slate-50 text-gray-900 resize-y focus:outline-none", - placeholder: "{placeholder}", - value: "{sql}", - oninput: move |ev| sql.set(ev.value()), - onkeydown: move |e: KeyboardEvent| { - if e.key() == Key::Enter && (e.modifiers().meta() || e.modifiers().ctrl()) { - e.prevent_default(); - if !run_query.pending() { - run_query.call(sql()); - } - } - }, - } - } - - div { class: "min-h-[4rem]", - if run_query.pending() { - LoadingState { message: Some("Running query…".to_string()) } - } else if let Some(Ok(df_signal)) = run_query.value() { - { - let df = df_signal(); - let rows = dataframe_row_count(&df); - let cols = df.names.len(); - rsx! { - div { class: "space-y-2", - div { class: "flex flex-wrap items-center gap-3 text-xs text-gray-500", - span { class: "font-medium text-gray-700", "Results" } - span { "{rows} rows" } - span { "·" } - span { "{cols} columns" } - } - div { class: "rounded-lg border border-gray-200 overflow-hidden", - DataFrameView { df: df.clone(), on_row_click: None } - } - } - } - } - } else if let Some(Err(err)) = run_query.value() { - AppErrorDisplay { - error: AppError::Api(err.to_string()), - title: Some("Query failed".to_string()), - } - } else { - div { - class: "rounded-lg border border-dashed border-gray-200 bg-gray-50/50 px-4 py-8 text-center text-sm text-gray-500", - "Run a query to see results here" - } - } - } - } - } -} - -#[component] -fn PreviewModal( - title: String, - preview: Action<(String,), DataFrame>, - on_close: EventHandler<()>, - on_use_in_editor: EventHandler, -) -> Element { - let preview_query = title - .split('•') - .next() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|fqtn| format!("SELECT * FROM {fqtn} LIMIT 10")) - .unwrap_or_default(); - - rsx! { - div { - class: "fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4", - tabindex: "-1", - onkeydown: move |e: KeyboardEvent| { - if e.key() == Key::Escape { - on_close.call(()); - } - }, - div { - class: "absolute inset-0 bg-slate-900/40 backdrop-blur-sm", - onclick: move |_| on_close.call(()), - } - div { - class: "relative w-full sm:max-w-5xl max-h-[90vh] flex flex-col bg-white sm:rounded-xl shadow-2xl border border-gray-200 overflow-hidden", - div { class: "flex items-start justify-between gap-3 px-4 py-3 border-b border-gray-200 bg-gray-50/80", - div { class: "min-w-0", - div { class: "flex items-center gap-2", - Icon { icon: &icondata::AiTableOutlined, class: "w-5 h-5 text-blue-600 shrink-0" } - h3 { class: "text-base font-semibold text-gray-900 truncate", "{title}" } - } - p { class: "text-xs text-gray-500 mt-0.5", "Preview · Esc to close" } - } - div { class: "flex items-center gap-2 shrink-0", - if !preview_query.is_empty() { - button { - class: format!( - "px-3 py-1.5 text-sm rounded-md border border-{} text-{} bg-{} hover:bg-{} transition-colors", - colors::CONTENT_ACCENT_BORDER, - colors::CONTENT_ACCENT_TEXT, - colors::CONTENT_ACCENT_BG, - colors::BTN_SECONDARY_HOVER, - ), - onclick: { - let q = preview_query.clone(); - move |_| on_use_in_editor.call(q.clone()) - }, - "Use in editor" - } - } - button { - class: format!( - "px-3 py-1.5 text-sm rounded-md bg-{} hover:bg-{} text-gray-700 transition-colors", - colors::BTN_SECONDARY_BG, - colors::BTN_SECONDARY_HOVER, - ), - onclick: move |_| on_close.call(()), - "Close" - } - } - } - div { class: "flex-1 overflow-auto p-4", - if preview.pending() { - LoadingState { message: Some("Loading preview…".to_string()) } - } else if let Some(Ok(df_signal)) = preview.value() { - { - let df = df_signal(); - let rows = dataframe_row_count(&df); - rsx! { - div { class: "space-y-2", - p { class: "text-xs text-gray-500", "{rows} rows" } - DataFrameView { df: df.clone(), on_row_click: None } - } - } - } - } else if let Some(Err(err)) = preview.value() { - AppErrorDisplay { - error: AppError::Api(err.to_string()), - title: Some("Preview failed".to_string()), - } - } else { - span { class: "text-gray-500 text-sm", "Preparing preview…" } - } - } - } - } - } -} - -fn dataframe_row_count(df: &DataFrame) -> usize { - df.cols.iter().map(|c| c.len()).max().unwrap_or(0) -} diff --git a/web/src/pages/cluster.rs b/web/src/pages/cluster.rs deleted file mode 100644 index 9cbf2a4a..00000000 --- a/web/src/pages/cluster.rs +++ /dev/null @@ -1,212 +0,0 @@ -use std::time::{Duration, SystemTime}; - -use chrono::{DateTime, Utc}; -use dioxus::prelude::*; -use probing_proto::prelude::Node; - -use crate::api::ApiClient; -use crate::components::card::Card; -use crate::components::colors::colors; -use crate::components::common::{AsyncBoundary, EmptyState, ErrorState}; -use crate::components::icon::Icon; -use crate::components::page::{PageContainer, PageTitle}; -use crate::components::poll_status::{ManualRefreshStatus, RefreshButton}; -use crate::components::stat_card::StatCard; -use crate::hooks::use_app_resource; - -#[component] -pub fn Cluster() -> Element { - let mut refresh = use_signal(|| 0u32); - let refresh_tick = refresh(); - let nodes = use_app_resource(move || { - let _ = refresh(); - async move { ApiClient::new().get_nodes().await } - }); - - rsx! { - PageContainer { - PageTitle { - title: "Cluster".to_string(), - subtitle: Some("Distributed training nodes and health".to_string()), - icon: Some(&icondata::AiClusterOutlined), - header_right: Some(rsx! { - ManualRefreshStatus { refresh_tick } - RefreshButton { - onclick: move |_| refresh.set(refresh() + 1), - } - }), - } - AsyncBoundary { - message: Some("Loading cluster nodes…".to_string()), - ClusterBody { nodes: nodes(), refresh } - } - } - } -} - -#[component] -fn ClusterBody( - nodes: Option, crate::utils::error::AppError>>, - refresh: Signal, -) -> Element { - let Some(result) = nodes else { - return rsx! { div {} }; - }; - - match result { - Err(err) => rsx! { - Card { - title: "Nodes", - ErrorState { - title: Some("Failed to load nodes".to_string()), - error: err.display_message(), - } - } - }, - Ok(nodes) if nodes.is_empty() => rsx! { - Card { - title: "Nodes", - EmptyState { message: "No cluster nodes registered.".to_string() } - } - }, - Ok(nodes) => { - let total = nodes.len(); - let healthy = nodes - .iter() - .filter(|n| node_status_tone(n) == NodeTone::Healthy) - .count(); - let degraded = nodes - .iter() - .filter(|n| node_status_tone(n) == NodeTone::Degraded) - .count(); - rsx! { - div { class: "space-y-4", - div { class: "flex flex-wrap items-center justify-between gap-2", - div { class: "grid grid-cols-2 sm:grid-cols-4 gap-3 flex-1", - StatCard { label: "Nodes", value: total.to_string(), hint: None } - StatCard { - label: "Healthy", - value: healthy.to_string(), - hint: None, - } - StatCard { - label: "Degraded", - value: degraded.to_string(), - hint: None, - } - StatCard { - label: "World size", - value: nodes - .first() - .and_then(|n| n.world_size) - .map(|w| w.to_string()) - .unwrap_or_else(|| "—".to_string()), - hint: None, - } - } - button { - class: format!( - "inline-flex items-center gap-1.5 px-3 py-2 text-xs rounded-md border border-{} text-{} bg-{} hover:bg-{}", - colors::CONTENT_ACCENT_BORDER, - colors::CONTENT_ACCENT_TEXT, - colors::CONTENT_ACCENT_BG, - colors::BTN_SECONDARY_HOVER, - ), - onclick: move |_| refresh.set(refresh() + 1), - Icon { icon: &icondata::AiReloadOutlined, class: "w-3.5 h-3.5" } - "Refresh nodes" - } - } - Card { - title: "Nodes", - content_class: Some("p-0"), - ClusterTable { nodes } - } - } - } - } - } -} - -#[derive(PartialEq)] -enum NodeTone { - Healthy, - Degraded, - Unknown, -} - -fn node_status_tone(node: &Node) -> NodeTone { - match node.status.as_deref().unwrap_or("").to_lowercase().as_str() { - "ok" | "healthy" | "running" | "ready" | "online" => NodeTone::Healthy, - "failed" | "error" | "offline" | "unhealthy" => NodeTone::Degraded, - "" => NodeTone::Unknown, - _ => NodeTone::Degraded, - } -} - -#[component] -fn ClusterTable(nodes: Vec) -> Element { - rsx! { - div { class: "overflow-x-auto", - table { class: "w-full border-collapse text-sm", - thead { - tr { class: "bg-gray-50 border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500", - th { class: "px-4 py-2 font-medium", "Host" } - th { class: "px-4 py-2 font-medium", "Address" } - th { class: "px-4 py-2 font-medium", "Rank" } - th { class: "px-4 py-2 font-medium", "Role" } - th { class: "px-4 py-2 font-medium", "Status" } - th { class: "px-4 py-2 font-medium", "Last seen" } - } - } - tbody { - for node in nodes { - { - let datetime: DateTime = (SystemTime::UNIX_EPOCH - + Duration::from_micros(node.timestamp)) - .into(); - let timestamp_str = datetime.format("%H:%M:%S").to_string(); - let url = format!("http://{}", node.addr); - let tone = node_status_tone(&node); - let (badge_bg, badge_text) = match tone { - NodeTone::Healthy => ("bg-emerald-50 text-emerald-700 border-emerald-200", "Healthy"), - NodeTone::Degraded => ("bg-red-50 text-red-700 border-red-200", "Issue"), - NodeTone::Unknown => ("bg-gray-100 text-gray-600 border-gray-200", "Unknown"), - }; - let status_label = node.status.clone().unwrap_or_else(|| badge_text.to_string()); - rsx! { - tr { class: "border-b border-gray-100 hover:bg-gray-50/80", - td { class: "px-4 py-2.5 font-medium text-gray-900", "{node.host}" } - td { class: "px-4 py-2.5", - a { - href: "{url}", - target: "_blank", - class: format!("text-{} hover:underline font-mono text-xs", colors::PRIMARY), - "{node.addr}" - } - } - td { class: "px-4 py-2.5 font-mono text-gray-700", - {node.rank.map(|r| r.to_string()).unwrap_or_else(|| "—".to_string())} - if let Some(world) = node.world_size { - span { class: "text-gray-400", " / {world}" } - } - } - td { class: "px-4 py-2.5 text-gray-600", - {node.role_name.clone().unwrap_or_else(|| "—".to_string())} - } - td { class: "px-4 py-2.5", - span { - class: "inline-flex px-2 py-0.5 rounded-full text-[11px] font-medium border {badge_bg}", - "{status_label}" - } - } - td { class: "px-4 py-2.5 font-mono text-xs text-gray-500", "{timestamp_str}" } - } - } - } - } - } - } - } - } -} diff --git a/web/src/pages/dashboard.rs b/web/src/pages/dashboard.rs deleted file mode 100644 index 72365543..00000000 --- a/web/src/pages/dashboard.rs +++ /dev/null @@ -1,1032 +0,0 @@ -//! Dashboard: live CPU/GPU metrics, process info, threads, env vars. - -use std::collections::HashMap; - -use dioxus::prelude::*; -use dioxus_router::use_navigator; -use probing_proto::prelude::Process; - -use crate::app::Route; - -use crate::api::{ - format_bytes, format_cpu_ms, format_opt_pct, format_pct, format_rss, gpu_device_label, - ApiClient, CpuHistorySample, CpuSnapshot, CpuThreadRow, GpuDeviceRow, GpuHistorySample, - GpuSnapshot, -}; -use crate::components::card::Card; -use crate::components::colors::colors; -use crate::components::common::{EmptyState, ErrorState, LoadingState}; -use crate::components::cpu_threads_table::CpuThreadsTable; -use crate::components::data::KeyValueList; -use crate::components::page::{PageContainer, PageTitle}; -use crate::components::poll_status::PollStatusBar; -use crate::components::stat_card::StatCard; -use crate::hooks::{ - use_api, use_api_with_options, use_page_visible, use_poll_tick_gated, ApiFetchOptions, -}; -use crate::state::investigation::sync_overview_process_context; - -const CPU_POLL_MS: u32 = 2000; -const ENV_VARS_PREVIEW: usize = 40; -const THREADS_PREVIEW: usize = 80; - -fn refresh_options() -> ApiFetchOptions { - ApiFetchOptions { - keep_previous_while_refreshing: true, - } -} - -#[component] -pub fn Dashboard() -> Element { - let visible = use_page_visible(); - let poll = use_poll_tick_gated(CPU_POLL_MS, Some(visible)); - let refresh = refresh_options(); - let poll_tick = poll(); - - let overview = use_api(|| { - let client = ApiClient::new(); - async move { client.get_overview().await } - }); - - let cpu_latest = use_api_with_options( - move || { - let _ = poll(); - let client = ApiClient::new(); - async move { client.fetch_cpu_latest().await } - }, - refresh, - ); - - let cpu_history = use_api_with_options( - move || { - let _ = poll(); - let client = ApiClient::new(); - async move { client.fetch_cpu_history(60).await } - }, - refresh, - ); - - let cpu_threads = use_api_with_options( - move || { - let _ = poll(); - let client = ApiClient::new(); - async move { client.fetch_cpu_top_threads(15).await } - }, - refresh, - ); - - let gpu_devices = use_api_with_options( - move || { - let _ = poll(); - let client = ApiClient::new(); - async move { client.fetch_gpu_devices().await } - }, - refresh, - ); - - let gpu_latest = use_api_with_options( - move || { - let _ = poll(); - let client = ApiClient::new(); - async move { client.fetch_gpu_latest().await } - }, - refresh, - ); - - let gpu_history = use_api_with_options( - move || { - let _ = poll(); - let client = ApiClient::new(); - async move { client.fetch_gpu_history(60).await } - }, - refresh, - ); - - let show_gpu = gpu_has_data(&gpu_devices, &gpu_latest); - - rsx! { - PageContainer { - PageTitle { - title: "Dashboard".to_string(), - subtitle: Some(if show_gpu { - "Live CPU and GPU utilization, memory, and process overview".to_string() - } else { - "Live CPU time (user / kernel) and process overview".to_string() - }), - icon: Some(&icondata::AiLineChartOutlined), - header_right: Some(rsx! { - PollStatusBar { - interval_secs: CPU_POLL_MS / 1000, - poll_tick, - } - }), - } - {cpu_section(&cpu_latest, &cpu_history, &cpu_threads)} - if show_gpu { - {gpu_section(&gpu_devices, &gpu_latest, &gpu_history)} - } - {process_section(&overview)} - } - } -} - -fn gpu_has_data( - devices: &crate::hooks::ApiState>, - latest: &crate::hooks::ApiState>, -) -> bool { - if let Some(Ok(devs)) = devices.data.read().as_ref() { - if !devs.is_empty() { - return true; - } - } - if let Some(Ok(snaps)) = latest.data.read().as_ref() { - if !snaps.is_empty() { - return true; - } - } - false -} - -fn cpu_section( - latest: &crate::hooks::ApiState>, - history: &crate::hooks::ApiState>, - threads: &crate::hooks::ApiState>, -) -> Element { - rsx! { - div { class: "space-y-4 mb-6", - {cpu_summary_row(latest)} - Card { - title: "CPU Time Trend (per sample)", - content_class: Some("p-4"), - {cpu_trend_panel(latest, history)} - } - Card { - title: "Top CPU Threads", - content_class: Some("p-4"), - {cpu_threads_panel(threads)} - } - } - } -} - -fn cpu_summary_row(state: &crate::hooks::ApiState>) -> Element { - if state.is_loading() { - return rsx! { - div { class: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4", - for _ in 0..5 { - div { class: "bg-white border border-gray-200 rounded-lg px-5 py-4 h-24 animate-pulse" } - } - } - }; - } - - match state.data.read().as_ref() { - Some(Ok(Some(snap))) => rsx! { - div { class: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4", - StatCard { - label: "User CPU", - value: format_cpu_ms(snap.delta_user_ns), - hint: Some(format!("{} · {}", format_pct(snap.cpu_user_pct), snap.platform)), - } - StatCard { - label: "Kernel CPU", - value: format_cpu_ms(snap.delta_sys_ns), - hint: Some(format_pct(snap.cpu_sys_pct)), - } - StatCard { - label: "Total CPU", - value: format_cpu_ms(snap.delta_total_ns), - hint: Some(format_pct(snap.cpu_total_pct)), - } - StatCard { - label: "Memory (RSS)", - value: format_rss(snap.rss_kb), - hint: Some(format!("{} threads", snap.thread_count)), - } - StatCard { - label: "Context Switches", - value: format!("{}", snap.delta_vol_ctxt + snap.delta_invol_ctxt), - hint: Some(format!( - "vol {} · invol {}", - snap.delta_vol_ctxt, snap.delta_invol_ctxt - )), - } - } - }, - Some(Ok(None)) => rsx! { - div { - class: "bg-white border border-dashed border-gray-300 rounded-lg px-5 py-6 text-center text-sm text-gray-500", - "Collecting CPU samples… refresh in a few seconds." - } - }, - Some(Err(e)) => rsx! { - Card { - title: "CPU Metrics", - ErrorState { error: e.display_message(), title: Some("Failed to load CPU metrics".to_string()) } - } - }, - _ => rsx! { div {} }, - } -} - -fn cpu_trend_panel( - latest: &crate::hooks::ApiState>, - history: &crate::hooks::ApiState>, -) -> Element { - if history.is_loading() && latest.is_loading() { - return rsx! { LoadingState { message: Some("Loading CPU history…".to_string()) } }; - } - - if let Some(Err(e)) = history.data.read().as_ref() { - return rsx! { - ErrorState { error: e.display_message(), title: None } - }; - } - - let samples = history - .data - .read() - .as_ref() - .and_then(|r| r.as_ref().ok()) - .cloned() - .unwrap_or_default(); - - if samples.is_empty() { - return rsx! { - EmptyState { - message: "No CPU history yet. Sampling runs every second once the collector starts.".to_string() - } - }; - } - - let current = latest - .data - .read() - .as_ref() - .and_then(|r| r.as_ref().ok()) - .and_then(|o| o.clone()) - .map(|s| { - format!( - "{} user · {} sys", - format_cpu_ms(s.delta_user_ns), - format_cpu_ms(s.delta_sys_ns) - ) - }) - .unwrap_or_else(|| "—".to_string()); - - rsx! { - div { class: "space-y-3", - div { class: "flex items-baseline justify-between gap-4 flex-wrap", - span { class: "text-sm text-gray-500", "Latest sample" } - span { class: "text-sm font-semibold text-gray-900", "{current}" } - } - div { class: "flex items-center gap-4 text-xs text-gray-500", - span { class: "inline-flex items-center gap-1", - span { class: "w-2.5 h-2.5 rounded-sm bg-blue-500" } - "User" - } - span { class: "inline-flex items-center gap-1", - span { class: "w-2.5 h-2.5 rounded-sm bg-amber-500" } - "Kernel" - } - } - CpuTimeSparkline { samples: samples.clone() } - div { class: "flex flex-wrap items-center justify-between gap-2", - p { class: "text-xs text-gray-400", - "Updates every {CPU_POLL_MS / 1000}s · click the latest bar to open profile for this interval" - } - ProfileExemplarButton {} - } - } - } -} - -fn cpu_threads_panel(state: &crate::hooks::ApiState>) -> Element { - if state.is_loading() { - return rsx! { LoadingState { message: Some("Loading thread CPU…".to_string()) } }; - } - match state.data.read().as_ref() { - Some(Ok(rows)) if !rows.is_empty() => { - rsx! { - div { class: "space-y-3", - p { class: "text-xs text-gray-500", - "Ranked by CPU time in the latest sample. Use Stack / Spans / Profile to investigate." - } - CpuThreadsTable { threads: rows.clone() } - } - } - } - Some(Err(e)) => rsx! { - ErrorState { error: e.display_message(), title: None } - }, - _ => rsx! { - EmptyState { - message: "No thread-level CPU data yet — wait for a few sampling intervals.".to_string() - } - }, - } -} - -#[component] -fn ProfileExemplarButton() -> Element { - let nav = use_navigator(); - rsx! { - button { - class: "text-xs font-medium text-blue-700 hover:underline whitespace-nowrap", - title: "Open CPU profile (pprof) for the current sampling window", - onclick: move |_| { - crate::state::investigation::clear_profiling_thread_filter(); - nav.push(Route::ProfilingViewPage { - view: "pprof".to_string(), - }); - }, - "Profile latest interval →" - } - } -} - -#[component] -fn CpuTimeSparkline(samples: Vec) -> Element { - let nav = use_navigator(); - let max = samples - .iter() - .map(|s| s.total_ms) - .fold(0.0f32, f32::max) - .max(1.0); - let latest_idx = samples.len().saturating_sub(1); - - rsx! { - div { - class: "flex items-end gap-0.5 h-20 w-full", - for (i, s) in samples.iter().enumerate() { - { - let is_latest = i == latest_idx; - let bar_class = if is_latest { - "flex-1 flex flex-col justify-end min-w-[3px] h-full gap-px cursor-pointer ring-1 ring-blue-400/50 rounded-sm" - } else { - "flex-1 flex flex-col justify-end min-w-[3px] h-full gap-px" - }; - rsx! { - div { - key: "{i}", - class: "{bar_class}", - title: if is_latest { - format!( - "Latest: user {:.1} ms · sys {:.1} ms — click to open profile", - s.user_ms, s.sys_ms - ) - } else { - format!("user {:.1} ms · sys {:.1} ms", s.user_ms, s.sys_ms) - }, - onclick: move |_| { - if is_latest { - crate::state::investigation::clear_profiling_thread_filter(); - nav.push(Route::ProfilingViewPage { - view: "pprof".to_string(), - }); - } - }, - div { - class: "w-full rounded-sm bg-amber-500/85 hover:bg-amber-600 transition-colors", - style: "height: {(s.sys_ms / max * 100.0).max(0.0)}%", - } - div { - class: "w-full rounded-sm bg-blue-500/85 hover:bg-blue-600 transition-colors", - style: "height: {(s.user_ms / max * 100.0).max(0.0)}%", - } - } - } - } - } - } - } -} - -fn gpu_section( - devices: &crate::hooks::ApiState>, - latest: &crate::hooks::ApiState>, - history: &crate::hooks::ApiState>>, -) -> Element { - rsx! { - div { class: "space-y-4 mb-6", - Card { - title: "GPU", - content_class: Some("p-4"), - {gpu_devices_panel(devices, latest, history)} - } - } - } -} - -/// Adaptive grid columns from device count (1 → full width, 8 → wraps in 4 cols). -fn gpu_grid_class(device_count: usize) -> &'static str { - match device_count { - 0 | 1 => "grid-cols-1", - 2 => "grid-cols-1 md:grid-cols-2", - 3 => "grid-cols-1 md:grid-cols-2 xl:grid-cols-3", - 4 => "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4", - _ => "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4", - } -} - -fn gpu_util_pct_value(snap: &GpuSnapshot) -> f32 { - snap.gpu_util_pct.unwrap_or(0.0).clamp(0.0, 100.0) -} - -fn gpu_util_hint(snap: &GpuSnapshot) -> String { - if snap.backend == "metal" { - format!( - "renderer {} · tiler {}", - format_opt_pct(snap.renderer_util_pct), - format_opt_pct(snap.tiler_util_pct) - ) - } else { - format!("mem ctrl {}", format_opt_pct(snap.mem_controller_util_pct)) - } -} - -fn gpu_devices_panel( - devices: &crate::hooks::ApiState>, - latest: &crate::hooks::ApiState>, - history: &crate::hooks::ApiState>>, -) -> Element { - if devices.is_loading() && latest.is_loading() { - return rsx! { LoadingState { message: Some("Loading GPU metrics…".to_string()) } }; - } - - if let Some(Err(e)) = devices.data.read().as_ref() { - return rsx! { - ErrorState { error: e.display_message(), title: Some("Failed to load GPU devices".to_string()) } - }; - } - if let Some(Err(e)) = latest.data.read().as_ref() { - return rsx! { - ErrorState { error: e.display_message(), title: Some("Failed to load GPU utilization".to_string()) } - }; - } - - let latest_map: HashMap = latest - .data - .read() - .as_ref() - .and_then(|r| r.as_ref().ok()) - .cloned() - .unwrap_or_default() - .into_iter() - .map(|s| (s.device_id, s)) - .collect(); - - let device_rows: Vec = devices - .data - .read() - .as_ref() - .and_then(|r| r.as_ref().ok()) - .cloned() - .unwrap_or_default(); - - let mut snapshots: Vec = latest_map.into_values().collect(); - snapshots.sort_by_key(|s| s.device_id); - - if device_rows.is_empty() && snapshots.is_empty() { - return rsx! { - EmptyState { message: "No GPU devices detected.".to_string() } - }; - } - - let device_count = snapshots.len().max(device_rows.len()); - let grid_class = gpu_grid_class(device_count); - let featured = device_count == 1; - - rsx! { - div { class: "space-y-5", - if !snapshots.is_empty() { - if featured { - {gpu_hero_panel(&snapshots[0])} - } else { - div { - class: "grid {grid_class} gap-4", - for snap in snapshots.iter() { - {gpu_device_card(snap)} - } - } - } - } - {gpu_trend_panel(&snapshots, history, featured, grid_class)} - if !featured || device_rows.len() > 1 { - {gpu_devices_table(&device_rows)} - } - } - } -} - -fn gpu_hero_panel(snap: &GpuSnapshot) -> Element { - let util_hint = gpu_util_hint(snap); - let gpu_pct = gpu_util_pct_value(snap); - let mem_hint = format!( - "{} / {} used", - format_bytes(snap.used_bytes), - format_bytes(snap.total_bytes) - ); - - rsx! { - div { class: "rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden", - div { class: "bg-gradient-to-r from-violet-50 via-white to-emerald-50 px-6 py-5 border-b border-gray-100", - div { class: "flex flex-wrap items-start justify-between gap-4", - div { class: "min-w-0 flex-1", - p { class: "text-xs font-medium uppercase tracking-wide text-gray-500", - "Device" - } - h3 { class: "text-xl font-semibold text-gray-900 mt-1 truncate", - "{snap.name}" - } - p { class: "text-sm text-gray-500 mt-1", - "{snap.backend} · {snap.memory_model}" - if let Some(ref chip) = snap.chip { - " · {chip}" - } - } - } - span { class: "shrink-0 text-xs font-semibold px-3 py-1.5 rounded-full bg-violet-100 text-violet-800", - "GPU {snap.device_id}" - } - } - } - div { class: "px-6 py-6 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6", - GpuHeroMetric { - label: "GPU Utilization", - value: format_opt_pct(snap.gpu_util_pct), - pct: gpu_pct, - bar_class: "bg-violet-500", - value_class: "text-violet-700", - hint: Some(util_hint), - } - GpuHeroMetric { - label: "Memory Used", - value: format_pct(snap.mem_used_pct), - pct: snap.mem_used_pct, - bar_class: "bg-emerald-500", - value_class: "text-emerald-700", - hint: Some(mem_hint), - } - div { class: "sm:col-span-2 xl:col-span-2 flex flex-col justify-center gap-3", - div { class: "grid grid-cols-2 gap-4", - div { - p { class: "text-xs font-medium text-gray-500 uppercase tracking-wide", "Free" } - p { class: "text-lg font-semibold text-gray-900 mt-1", "{format_bytes(snap.free_bytes)}" } - } - div { - p { class: "text-xs font-medium text-gray-500 uppercase tracking-wide", "Total" } - p { class: "text-lg font-semibold text-gray-900 mt-1", "{format_bytes(snap.total_bytes)}" } - } - } - GpuProgressBar { pct: snap.mem_used_pct, bar_class: "bg-emerald-500" } - } - } - } - } -} - -#[component] -fn GpuHeroMetric( - label: &'static str, - value: String, - pct: f32, - bar_class: &'static str, - value_class: &'static str, - #[props(optional)] hint: Option, -) -> Element { - rsx! { - div { class: "space-y-3", - p { class: "text-xs font-medium text-gray-500 uppercase tracking-wide", "{label}" } - p { class: "text-3xl font-bold {value_class}", "{value}" } - GpuProgressBar { pct, bar_class } - if let Some(h) = hint { - p { class: "text-xs text-gray-500", "{h}" } - } - } - } -} - -#[component] -fn GpuProgressBar(pct: f32, bar_class: &'static str) -> Element { - let width = pct.clamp(0.0, 100.0); - rsx! { - div { class: "h-2.5 rounded-full bg-gray-100 overflow-hidden", - div { - class: "h-full rounded-full transition-all duration-300 {bar_class}", - style: "width: {width}%", - } - } - } -} - -fn gpu_device_card(snap: &GpuSnapshot) -> Element { - let util_hint = gpu_util_hint(snap); - let gpu_pct = gpu_util_pct_value(snap); - - rsx! { - div { class: "bg-white border border-gray-200 rounded-xl px-5 py-4 shadow-sm space-y-4", - div { class: "flex items-start justify-between gap-2", - div { class: "min-w-0", - div { class: "text-xs font-medium text-gray-500 uppercase tracking-wide truncate", - "{gpu_device_label(snap.device_id, &snap.name)}" - } - if let Some(ref chip) = snap.chip { - div { class: "text-xs text-gray-400 mt-0.5 truncate", "{chip}" } - } - } - span { class: "shrink-0 text-[10px] font-medium px-2 py-0.5 rounded bg-gray-100 text-gray-600", - "{snap.backend}" - } - } - div { class: "space-y-3", - div { - div { class: "flex items-baseline justify-between gap-2 mb-1.5", - span { class: "text-xs text-gray-500", "GPU Util" } - span { class: "text-xl font-bold text-violet-700", - "{format_opt_pct(snap.gpu_util_pct)}" - } - } - GpuProgressBar { pct: gpu_pct, bar_class: "bg-violet-500" } - } - div { - div { class: "flex items-baseline justify-between gap-2 mb-1.5", - span { class: "text-xs text-gray-500", "Memory" } - span { class: "text-xl font-bold text-emerald-700", - "{format_pct(snap.mem_used_pct)}" - } - } - GpuProgressBar { pct: snap.mem_used_pct, bar_class: "bg-emerald-500" } - } - } - div { class: "text-xs text-gray-500 pt-1 border-t border-gray-100", - "{format_bytes(snap.used_bytes)} / {format_bytes(snap.total_bytes)} · {util_hint}" - } - } - } -} - -fn gpu_devices_table(devices: &[GpuDeviceRow]) -> Element { - if devices.is_empty() { - return rsx! { div {} }; - } - rsx! { - div { class: "overflow-x-auto border border-gray-200 rounded-lg", - table { class: "min-w-full text-sm", - thead { class: "bg-gray-50 text-left text-xs uppercase text-gray-500", - tr { - th { class: "px-3 py-2", "ID" } - th { class: "px-3 py-2", "Name" } - th { class: "px-3 py-2", "Backend" } - th { class: "px-3 py-2", "Memory" } - th { class: "px-3 py-2", "Chip / CC" } - } - } - tbody { - for d in devices { - tr { class: "border-t border-gray-100", - td { class: "px-3 py-2 font-mono", "{d.device_id}" } - td { class: "px-3 py-2", "{d.name}" } - td { class: "px-3 py-2 text-gray-600", "{d.backend}" } - td { class: "px-3 py-2", "{format_bytes(d.total_mem_bytes)}" } - td { class: "px-3 py-2 text-gray-600", - {d.chip.clone().or(d.compute_capability.clone()).unwrap_or_else(|| "—".to_string())} - } - } - } - } - } - } - } -} - -fn gpu_trend_panel( - snapshots: &[GpuSnapshot], - history: &crate::hooks::ApiState>>, - featured: bool, - grid_class: &str, -) -> Element { - if history.is_loading() && snapshots.is_empty() { - return rsx! { LoadingState { message: Some("Loading GPU history…".to_string()) } }; - } - - if let Some(Err(e)) = history.data.read().as_ref() { - return rsx! { ErrorState { error: e.display_message(), title: None } }; - } - - let history_map = history - .data - .read() - .as_ref() - .and_then(|r| r.as_ref().ok()) - .cloned() - .unwrap_or_default(); - - if history_map.is_empty() { - return rsx! { - EmptyState { - message: "Collecting GPU samples… refresh in a few seconds.".to_string() - } - }; - } - - let mut device_ids: Vec = history_map.keys().copied().collect(); - device_ids.sort(); - - let names: HashMap = snapshots - .iter() - .map(|s| (s.device_id, s.name.clone())) - .collect(); - - let sparkline_height = if featured { "h-32" } else { "h-20" }; - let trend_title = if featured { - "Utilization trend" - } else { - "Utilization trend (per device)" - }; - - rsx! { - div { - class: if featured { - "rounded-xl border border-gray-200 bg-gray-50/80 p-5 space-y-4" - } else { - "space-y-4 pt-2 border-t border-gray-100" - }, - div { class: "flex flex-wrap items-center justify-between gap-3", - p { - class: if featured { "text-sm font-semibold text-gray-800" } else { "text-sm font-medium text-gray-700" }, - "{trend_title}" - } - div { class: "flex items-center gap-4 text-xs text-gray-500", - span { class: "inline-flex items-center gap-1.5", - span { class: "w-2.5 h-2.5 rounded-sm bg-violet-500" } - "GPU util %" - } - span { class: "inline-flex items-center gap-1.5", - span { class: "w-2.5 h-2.5 rounded-sm bg-emerald-500" } - "Memory used %" - } - } - } - div { - class: "grid {grid_class} gap-4", - for id in device_ids { - { - let samples = history_map.get(&id).cloned().unwrap_or_default(); - let label = names - .get(&id) - .map(|n| gpu_device_label(id, n)) - .unwrap_or_else(|| format!("GPU {id}")); - rsx! { - div { - class: if featured { - "bg-white border border-gray-200 rounded-lg p-4 space-y-3 shadow-sm" - } else { - "bg-white border border-gray-100 rounded-lg p-3 space-y-2" - }, - if !featured { - div { class: "text-xs font-medium text-gray-700", "{label}" } - } - GpuUtilSparkline { samples: samples.clone(), height_class: sparkline_height } - } - } - } - } - } - p { class: "text-xs text-gray-400", - "Updates every {CPU_POLL_MS / 1000}s · one series per device" - } - } - } -} - -#[component] -fn GpuUtilSparkline(samples: Vec, height_class: &'static str) -> Element { - let max = samples - .iter() - .map(|s| s.mem_used_pct.max(s.gpu_util_pct)) - .fold(0.0f32, f32::max) - .max(1.0); - - rsx! { - div { - class: "flex items-end gap-0.5 w-full {height_class}", - for (i, s) in samples.iter().enumerate() { - div { - key: "{i}", - class: "flex-1 flex flex-col justify-end min-w-[3px] h-full gap-px", - title: format!("gpu {:.0}% · mem {:.0}%", s.gpu_util_pct, s.mem_used_pct), - div { - class: "w-full rounded-sm bg-emerald-500/85 hover:bg-emerald-600 transition-colors", - style: "height: {(s.mem_used_pct / max * 100.0).max(0.0)}%", - } - div { - class: "w-full rounded-sm bg-violet-500/85 hover:bg-violet-600 transition-colors", - style: "height: {(s.gpu_util_pct / max * 100.0).max(0.0)}%", - } - } - } - } - } -} - -fn process_section(state: &crate::hooks::ApiState) -> Element { - if state.is_loading() { - return rsx! { - Card { - title: "Process Information", - LoadingState { message: Some("Loading process information…".to_string()) } - } - }; - } - let data = state.data.read(); - if let Some(Err(err)) = data.as_ref() { - return rsx! { - Card { - title: "Process Information", - ErrorState { error: err.display_message(), title: None } - } - }; - } - let Some(Ok(process)) = data.as_ref() else { - return rsx! { div {} }; - }; - rsx! { - div { class: "space-y-4", - ProcessContextSync { process: process.clone() } - Card { - title: "Process Information", - KeyValueList { - items: vec![ - ("Process ID (PID):", process.pid.to_string()), - ("Executable Path:", process.exe.clone()), - ("Command Line:", process.cmd.clone()), - ("Working Directory:", process.cwd.clone()), - ] - } - } - Card { - title: "Threads Information", - div { - class: "space-y-3", - div { class: "text-sm text-gray-600", "Total threads: {process.threads.len()}" } - ThreadsPreview { - threads: process.threads.clone(), - pid: process.pid, - } - } - } - Card { - title: "Environment Variables", - EnvVars { env: process.env.clone() } - } - } - } -} - -#[component] -fn ThreadsPreview(threads: Vec, pid: i32) -> Element { - let navigator = use_navigator(); - let mut expanded = use_signal(|| false); - let total = threads.len(); - let limit = if expanded() { - total - } else { - THREADS_PREVIEW.min(total) - }; - let hidden = total.saturating_sub(limit); - - rsx! { - div { class: "space-y-2", - div { class: "flex flex-wrap gap-2", - for tid in threads.iter().take(limit) { - { - let tid_i32 = *tid as i32; - let tid_str = tid.to_string(); - let tid_for_stack = tid_str.clone(); - rsx! { - div { - class: "inline-flex items-center gap-1.5 pl-2 pr-1 py-1 text-sm bg-blue-50 border border-blue-100 rounded-md font-mono", - span { - class: "text-blue-900 font-medium", - title: "Thread id", - "{tid}" - } - span { class: "text-blue-200", "|" } - button { - class: format!( - "text-xs font-medium text-{} hover:underline whitespace-nowrap", - colors::PRIMARY - ), - onclick: move |_| { - navigator.push(Route::StackWithTidPage { - tid: tid_for_stack.clone(), - }); - }, - "Stack" - } - button { - class: "text-xs font-medium text-gray-600 hover:underline whitespace-nowrap", - onclick: move |_| { - crate::state::investigation::set_thread_context( - tid_i32, - None, - Some(pid), - ); - navigator.push(Route::SpansPage {}); - }, - "Spans" - } - button { - class: format!( - "text-xs font-medium text-{} hover:underline whitespace-nowrap", - colors::CONTENT_ACCENT_TEXT - ), - onclick: move |_| { - crate::state::investigation::set_thread_context( - tid_i32, - None, - Some(pid), - ); - navigator.push(Route::ProfilingViewPage { - view: "pprof".to_string(), - }); - }, - "Profile" - } - } - } - } - } - } - if hidden > 0 { - button { - class: "text-sm text-blue-600 hover:underline", - onclick: move |_| expanded.set(true), - "Show {hidden} more threads…" - } - } else if expanded() && total > THREADS_PREVIEW { - button { - class: "text-sm text-blue-600 hover:underline", - onclick: move |_| expanded.set(false), - "Show less" - } - } - } - } -} - -#[component] -fn ProcessContextSync(process: Process) -> Element { - let pid = process.pid; - let exe = process.exe.clone(); - use_effect(move || { - sync_overview_process_context(pid, &exe); - }); - rsx! {} -} - -#[component] -fn EnvVars(env: std::collections::HashMap) -> Element { - let mut expanded = use_signal(|| false); - let total = env.len(); - let show_all = expanded(); - let preview_limit = if show_all { - total - } else { - ENV_VARS_PREVIEW.min(total) - }; - let mut entries: Vec<_> = env.into_iter().collect(); - entries.sort_by(|a, b| a.0.cmp(&b.0)); - let hidden = total.saturating_sub(preview_limit); - - rsx! { - div { - class: "space-y-3", - div { class: "text-sm text-gray-600", "Total environment variables: {total}" } - div { - class: "space-y-2", - for (name, value) in entries.into_iter().take(preview_limit) { - div { - class: "flex justify-between items-start py-2 border-b border-gray-200 last:border-b-0", - span { class: "font-medium text-gray-700 font-mono text-sm shrink-0 mr-4", "{name}" } - span { class: "font-mono text-sm bg-gray-100 text-gray-900 px-3 py-1.5 rounded break-all text-right", "{value}" } - } - } - } - if hidden > 0 { - button { - class: "text-sm text-blue-600 hover:underline", - onclick: move |_| expanded.set(true), - "Show {hidden} more…" - } - } else if show_all && total > ENV_VARS_PREVIEW { - button { - class: "text-sm text-blue-600 hover:underline", - onclick: move |_| expanded.set(false), - "Show less" - } - } - } - } -} diff --git a/web/src/pages/mod.rs b/web/src/pages/mod.rs deleted file mode 100644 index 1ac634f5..00000000 --- a/web/src/pages/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub mod agent; -pub mod analytics; -pub mod cluster; -pub mod dashboard; -pub mod profiling; -pub mod pulsing; -pub mod python; -pub mod rl; -pub mod stack; -pub mod traces; -pub mod training; diff --git a/web/src/pages/profiling.rs b/web/src/pages/profiling.rs deleted file mode 100644 index b0d265af..00000000 --- a/web/src/pages/profiling.rs +++ /dev/null @@ -1,263 +0,0 @@ -use dioxus::prelude::*; - -use crate::api::ApiClient; -use crate::components::common::AsyncBoundary; -use crate::components::flamegraph::{FlamegraphPayload, FlamegraphView}; -use crate::components::page::PageTitle; -use crate::components::profile_snapshot_bar::ProfileSnapshotBar; -use crate::components::profiling::{ - ProfilerDisabledNotice, ProfilingContentPanel, ProfilingErrorPanel, ProfilingFeedbackToast, - PytorchChromeTimelineLoader, RayChromeTimelineLoader, TimelinePlaceholder, - TraceChromeTimelineLoader, -}; -use crate::components::profiling_sidebar_hint::ProfilingSidebarHint; -use crate::hooks::use_app_resource; -use crate::state::investigation::{ - clear_profiling_thread_filter, INVESTIGATION_CONTEXT, PROFILING_THREAD_FILTER, -}; -use crate::state::profiling::{ - apply_profiler_config, normalize_profiling_view, profiling_view_spec, PROFILING_CHROME_LIMIT, - PROFILING_CONFIG_LOADED, PROFILING_PPROF_FREQ, PROFILING_PYTORCH_TIMELINE_RELOAD, - PROFILING_RAY_TIMELINE_RELOAD, PROFILING_TORCH_ENABLED, PROFILING_TRACE_RELOAD, -}; - -#[component] -pub fn Profiling(view: String) -> Element { - let current_view = normalize_profiling_view(&view).to_string(); - let spec = profiling_view_spec(¤t_view); - let title = spec.label.to_string(); - let subtitle = view_subtitle(¤t_view); - - rsx! { - ProfilingFeedbackToast {} - div { - class: "flex flex-col flex-1 min-h-0 h-full gap-4", - PageTitle { - title, - subtitle: Some(subtitle), - icon: Some(view_icon(¤t_view)), - } - ProfilingSidebarHint {} - div { class: "flex flex-col flex-1 min-h-0 min-w-0", - ProfilingContentPanel { - AsyncBoundary { - message: Some("Loading profiler configuration…".to_string()), - ProfilerConfigGate { key: "{current_view}", view: current_view } - } - } - } - } - } -} - -fn view_icon(view: &str) -> &'static icondata::Icon { - match view { - "pprof" => &icondata::CgPerformance, - "torch" => &icondata::SiPytorch, - "trace" => &icondata::AiThunderboltOutlined, - "pytorch" => &icondata::SiPytorch, - "ray" => &icondata::AiClockCircleOutlined, - _ => &icondata::AiSearchOutlined, - } -} - -fn view_subtitle(view: &str) -> String { - match view { - "pprof" => "SIGPROF stack explorer · statistical sampling".to_string(), - "torch" => "Module flamegraph from TorchProbe hooks".to_string(), - "trace" => "Chrome trace events from probing buffers — not distributed spans".to_string(), - "pytorch" => "PyTorch profiler chrome trace".to_string(), - "ray" => "Ray task timeline".to_string(), - _ => "Profiling views".to_string(), - } -} - -#[component] -fn ProfilerConfigGate(view: String) -> Element { - let trace_reload = *PROFILING_TRACE_RELOAD.read(); - let trace_limit = *PROFILING_CHROME_LIMIT.read(); - - let _config = use_app_resource(|| async move { - let client = ApiClient::new(); - let result = client.get_profiler_config().await; - match &result { - Ok(config) => apply_profiler_config(config), - Err(_) => *PROFILING_CONFIG_LOADED.write() = true, - } - result - }); - _config.suspend()?; - - match view.as_str() { - "pprof" | "torch" => rsx! { - AsyncBoundary { - message: Some("Loading flamegraph…".to_string()), - FlamegraphLoader { key: "{view}", view: view.clone() } - } - }, - "trace" => rsx! { - AsyncBoundary { - message: Some("Loading trace data…".to_string()), - TraceChromeTimelineLoader { - key: "{view}-{trace_reload}-{trace_limit}", - reload_key: trace_reload, - limit: trace_limit, - } - } - }, - "pytorch" => rsx! { - AsyncBoundary { - message: Some("Loading PyTorch timeline data…".to_string()), - PytorchTimelineLoader { key: "{view}" } - } - }, - "ray" => rsx! { - AsyncBoundary { - message: Some("Loading Ray timeline data…".to_string()), - RayTimelineLoader { key: "{view}" } - } - }, - _ => rsx! { div {} }, - } -} - -#[component] -fn FlamegraphLoader(view: String) -> Element { - let pprof_enabled = *PROFILING_PPROF_FREQ.read() > 0; - let torch_enabled = *PROFILING_TORCH_ENABLED.read(); - let profiler_name = if view == "pprof" { "pprof" } else { "torch" }; - - let profiler_active = match view.as_str() { - "pprof" => pprof_enabled, - "torch" => torch_enabled, - _ => false, - }; - - if !profiler_active { - return rsx! { - ProfilerDisabledNotice { profiler_name } - }; - } - - rsx! { - AsyncBoundary { - message: Some("Loading flamegraph…".to_string()), - FlamegraphData { key: "{profiler_name}", profiler_name: profiler_name.to_string() } - } - } -} - -#[component] -fn FlamegraphData(profiler_name: String) -> Element { - let is_torch = profiler_name == "torch"; - let is_pprof = profiler_name == "pprof"; - let mut metric = use_signal(|| "duration".to_string()); - let fetch_name = profiler_name.clone(); - let thread_tid = if is_pprof { - *PROFILING_THREAD_FILTER.read() - } else { - None - }; - let thread_label = INVESTIGATION_CONTEXT.read().label.clone(); - - let payload = use_app_resource(move || { - let name = fetch_name.clone(); - let m = metric(); - async move { - let client = ApiClient::new(); - let body = if name == "torch" { - client - .get_flamegraph_json_with_metric(&name, Some(&m)) - .await? - } else { - client.get_flamegraph_json(&name).await? - }; - let parsed: FlamegraphPayload = serde_json::from_str(&body).map_err(|e| { - crate::utils::error::AppError::Api(format!("Invalid flamegraph JSON: {e}")) - })?; - Ok(parsed) - } - }); - - match payload.suspend()?() { - Ok(data) => rsx! { - div { class: "flex flex-col flex-1 min-h-0 min-w-0", - if let Some(tid) = thread_tid { - div { - class: "px-4 py-2 text-xs bg-blue-50 border-b border-blue-100 flex flex-wrap items-center gap-2", - span { class: "text-blue-900", - "Thread filter: " - if let Some(label) = thread_label { - "{label}" - } else { - "tid {tid}" - } - } - button { - class: "text-blue-700 hover:underline font-medium", - onclick: move |_| clear_profiling_thread_filter(), - "Clear filter" - } - } - } - ProfileSnapshotBar { - key: "{profiler_name}-{metric()}", - profiler: profiler_name.clone(), - metric: if is_torch { Some(metric()) } else { None }, - payload: data.clone(), - } - FlamegraphView { - key: "{profiler_name}-{metric()}-thread-{thread_tid.unwrap_or(-1)}", - payload: data, - thread_tid, - torch_metric: if is_torch { Some(metric) } else { None }, - on_torch_metric: if is_torch { - Some(EventHandler::new(move |m: String| metric.set(m))) - } else { - None - }, - } - } - }, - Err(err) => rsx! { - ProfilingErrorPanel { - title: "Flamegraph Error".to_string(), - error: err.display_message(), - } - }, - } -} - -#[component] -fn PytorchTimelineLoader() -> Element { - let reload_key = *PROFILING_PYTORCH_TIMELINE_RELOAD.read(); - if reload_key == 0 { - return rsx! { - TimelinePlaceholder { - title: "PyTorch Profiler Timeline", - hint: "Use Start Profile and Load Timeline in the profiling controls.".to_string(), - } - }; - } - - rsx! { - PytorchChromeTimelineLoader { reload_key } - } -} - -#[component] -fn RayTimelineLoader() -> Element { - let reload_key = *PROFILING_RAY_TIMELINE_RELOAD.read(); - if reload_key == 0 { - return rsx! { - TimelinePlaceholder { - title: "Ray Timeline", - hint: "Click Reload Ray Timeline in the profiling controls.".to_string(), - } - }; - } - - rsx! { - RayChromeTimelineLoader { reload_key } - } -} diff --git a/web/src/pages/pulsing.rs b/web/src/pages/pulsing.rs deleted file mode 100644 index cef90eb9..00000000 --- a/web/src/pages/pulsing.rs +++ /dev/null @@ -1,561 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use dioxus::prelude::*; -use probing_proto::prelude::{DataFrame, Ele}; - -use crate::api::ApiClient; -use crate::components::card::Card; -use crate::components::common::{EmptyState, ErrorState, LoadingState}; -use crate::components::dataframe_view::DataFrameView; -use crate::components::page::{PageContainer, PageTitle}; -use crate::components::stat_card::StatCard; -use crate::hooks::use_api; - -#[component] -pub fn Pulsing() -> Element { - let actors = use_api(|| { - let c = ApiClient::new(); - async move { c.fetch_pulsing_actors().await } - }); - let span_count = use_api(|| { - let c = ApiClient::new(); - async move { c.fetch_pulsing_span_count().await } - }); - let spans = use_api(|| { - let c = ApiClient::new(); - async move { c.fetch_pulsing_spans().await } - }); - let metrics = use_api(|| { - let c = ApiClient::new(); - async move { c.fetch_pulsing_metrics().await } - }); - let members = use_api(|| { - let c = ApiClient::new(); - async move { c.fetch_pulsing_members().await } - }); - - let expanded_traces = use_signal(|| -> HashSet { (0..3).collect() }); - - rsx! { - PageContainer { - PageTitle { - title: "Pulsing Actors".to_string(), - subtitle: Some("Actor system overview, spans, metrics & membership".to_string()), - icon: Some(&icondata::AiDeploymentUnitOutlined), - } - {summary_row(&actors, &span_count, &members)} - - Card { - title: "Actors", - content_class: Some(""), - {query_result(&actors, "No actors registered")} - } - - Card { - title: "Trace Timeline", - content_class: Some(""), - {trace_timeline(&spans, expanded_traces)} - } - - Card { - title: "Metrics (latest 100)", - content_class: Some(""), - {query_result(&metrics, "No metrics recorded yet")} - } - - Card { - title: "Cluster Members", - content_class: Some(""), - {query_result(&members, "No cluster members (standalone mode)")} - } - } - } -} - -// ── helpers ────────────────────────────────────────────────────────── - -fn ele_str(e: &Ele) -> String { - match e { - Ele::Text(s) => s.to_string(), - Ele::I64(v) => v.to_string(), - Ele::I32(v) => v.to_string(), - Ele::F64(v) => v.to_string(), - _ => String::new(), - } -} - -fn ele_i64(e: &Ele) -> i64 { - match e { - Ele::I64(v) => *v, - Ele::I32(v) => *v as i64, - _ => 0, - } -} - -fn format_duration_us(us: f64) -> String { - if us < 1_000.0 { - format!("{:.0}µs", us) - } else if us < 1_000_000.0 { - format!("{:.1}ms", us / 1_000.0) - } else { - format!("{:.2}s", us / 1_000_000.0) - } -} - -/// Format a microsecond-epoch timestamp as `HH:MM:SS.mmm` (UTC). -fn format_timestamp_us(us: i64) -> String { - let total_sec = us / 1_000_000; - let millis = (us % 1_000_000) / 1_000; - let h = (total_sec / 3600) % 24; - let m = (total_sec / 60) % 60; - let s = total_sec % 60; - format!("{:02}:{:02}:{:02}.{:03}", h, m, s, millis) -} - -fn count_rows(state: &crate::hooks::ApiState) -> String { - match state.data.read().as_ref() { - Some(Ok(df)) => df.cols.first().map(|c| c.len()).unwrap_or(0).to_string(), - _ if state.is_loading() => "…".into(), - _ => "-".into(), - } -} - -fn scalar_count(state: &crate::hooks::ApiState) -> String { - match state.data.read().as_ref() { - Some(Ok(df)) => { - if let Some(col) = df.cols.first() { - match col.get(0) { - Ele::I64(v) => v.to_string(), - Ele::I32(v) => v.to_string(), - _ => "0".into(), - } - } else { - "0".into() - } - } - _ if state.is_loading() => "…".into(), - _ => "-".into(), - } -} - -// ── summary ────────────────────────────────────────────────────────── - -fn summary_row( - actors: &crate::hooks::ApiState, - span_count: &crate::hooks::ApiState, - members: &crate::hooks::ApiState, -) -> Element { - rsx! { - div { class: "grid grid-cols-1 sm:grid-cols-3 gap-4", - StatCard { label: "Live Actors", value: count_rows(actors) } - StatCard { label: "Spans Captured", value: scalar_count(span_count) } - StatCard { label: "Cluster Members", value: count_rows(members) } - } - } -} - -fn query_result(state: &crate::hooks::ApiState, empty_msg: &str) -> Element { - if state.is_loading() { - return rsx! { LoadingState { message: Some("Loading…".to_string()) } }; - } - match state.data.read().as_ref() { - Some(Ok(df)) if df.cols.first().map(|c| c.len()).unwrap_or(0) > 0 => { - rsx! { DataFrameView { df: df.clone(), on_row_click: None } } - } - Some(Err(e)) => rsx! { ErrorState { error: e.display_message(), title: None } }, - _ => rsx! { EmptyState { message: empty_msg.to_string() } }, - } -} - -// ── Span data model ────────────────────────────────────────────────── - -#[derive(Clone)] -struct SpanRow { - trace_id: String, - span_id: String, - parent_span_id: String, - name: String, - kind: String, - start_us: i64, - end_us: i64, - duration_us: i64, - status: String, - actor: String, -} - -fn parse_spans(df: &DataFrame) -> Vec { - let col = |name: &str| df.names.iter().position(|n| n == name); - let ci_tid = col("trace_id"); - let ci_sid = col("span_id"); - let ci_pid = col("parent_span_id"); - let ci_name = col("name"); - let ci_kind = col("kind"); - let ci_start = col("start_us"); - let ci_end = col("end_us"); - let ci_dur = col("duration_us"); - let ci_status = col("status_code"); - let ci_actor = col("attr_actor_name"); - - let nrows = df.cols.first().map(|c| c.len()).unwrap_or(0); - (0..nrows) - .map(|i| SpanRow { - trace_id: ci_tid - .map(|c| ele_str(&df.cols[c].get(i))) - .unwrap_or_default(), - span_id: ci_sid - .map(|c| ele_str(&df.cols[c].get(i))) - .unwrap_or_default(), - parent_span_id: ci_pid - .map(|c| ele_str(&df.cols[c].get(i))) - .unwrap_or_default(), - name: ci_name - .map(|c| ele_str(&df.cols[c].get(i))) - .unwrap_or_default(), - kind: ci_kind - .map(|c| ele_str(&df.cols[c].get(i))) - .unwrap_or_default(), - start_us: ci_start.map(|c| ele_i64(&df.cols[c].get(i))).unwrap_or(0), - end_us: ci_end.map(|c| ele_i64(&df.cols[c].get(i))).unwrap_or(0), - duration_us: ci_dur.map(|c| ele_i64(&df.cols[c].get(i))).unwrap_or(0), - status: ci_status - .map(|c| ele_str(&df.cols[c].get(i))) - .unwrap_or_default(), - actor: ci_actor - .map(|c| ele_str(&df.cols[c].get(i))) - .unwrap_or_default(), - }) - .collect() -} - -struct FlatSpan { - span: SpanRow, - depth: usize, - /// Actor name of the parent span (the caller), empty for root spans. - caller: String, - /// Self time = wall time minus direct children's duration (clamped ≥ 0). - self_time_us: i64, -} - -/// Build tree-ordered flat list: for each trace, DFS from root spans. -fn flatten_traces(spans: &[SpanRow]) -> Vec<(String, Vec)> { - let mut by_trace: HashMap<&str, Vec<&SpanRow>> = HashMap::new(); - for s in spans { - by_trace.entry(&s.trace_id).or_default().push(s); - } - - let mut traces: Vec<(String, Vec)> = Vec::new(); - - for (tid, group) in &by_trace { - let children_of: HashMap<&str, Vec<&SpanRow>> = { - let mut m: HashMap<&str, Vec<&SpanRow>> = HashMap::new(); - for s in group { - m.entry(s.parent_span_id.as_str()).or_default().push(s); - } - m - }; - let span_ids: std::collections::HashSet<&str> = - group.iter().map(|s| s.span_id.as_str()).collect(); - - let mut roots: Vec<&&SpanRow> = group - .iter() - .filter(|s| { - s.parent_span_id.is_empty() || !span_ids.contains(s.parent_span_id.as_str()) - }) - .collect(); - roots.sort_by_key(|s| s.start_us); - - let mut flat = Vec::new(); - // Stack: (span, depth, parent_actor_name) - let mut stack: Vec<(&SpanRow, usize, String)> = roots - .into_iter() - .rev() - .map(|s| (*s, 0, String::new())) - .collect(); - - while let Some((span, depth, caller)) = stack.pop() { - let this_actor = span.actor.clone(); - let kids = children_of.get(span.span_id.as_str()); - let children_dur: i64 = kids - .map(|k| k.iter().map(|c| c.duration_us).sum()) - .unwrap_or(0); - let self_time_us = (span.duration_us - children_dur).max(0); - - flat.push(FlatSpan { - span: span.clone(), - depth, - caller, - self_time_us, - }); - if let Some(kids) = kids { - let mut sorted: Vec<&&SpanRow> = kids.iter().collect(); - sorted.sort_by_key(|s| std::cmp::Reverse(s.start_us)); - for kid in sorted { - stack.push((kid, depth + 1, this_actor.clone())); - } - } - } - if !flat.is_empty() { - traces.push((tid.to_string(), flat)); - } - } - - traces.sort_by(|a, b| { - let a_start = a.1.first().map(|f| f.span.start_us).unwrap_or(0); - let b_start = b.1.first().map(|f| f.span.start_us).unwrap_or(0); - b_start.cmp(&a_start) - }); - traces -} - -// ── Trace Timeline rendering ───────────────────────────────────────── - -fn trace_timeline( - state: &crate::hooks::ApiState, - expanded: Signal>, -) -> Element { - if state.is_loading() { - return rsx! { LoadingState { message: Some("Loading spans…".to_string()) } }; - } - let data_ref = state.data.read(); - let df = match data_ref.as_ref() { - Some(Ok(df)) => df, - Some(Err(e)) => return rsx! { ErrorState { error: e.display_message(), title: None } }, - None => return rsx! { EmptyState { message: "No spans".to_string() } }, - }; - - let spans = parse_spans(df); - if spans.is_empty() { - return rsx! { EmptyState { message: "No spans recorded yet".to_string() } }; - } - - let traces = flatten_traces(&spans); - let max_traces = 30; - - rsx! { - div { class: "space-y-3", - // Legend - div { class: "flex items-center gap-5 px-1 py-1.5 text-xs text-gray-500", - div { class: "flex items-center gap-1.5", - div { class: "w-5 h-3 rounded bg-emerald-500" } - span { "Self time — span 自身执行" } - } - div { class: "flex items-center gap-1.5", - div { class: "w-5 h-3 rounded bg-emerald-200" } - span { "Wall time — 含子调用的总耗时" } - } - div { class: "flex items-center gap-1.5", - div { class: "w-5 h-3 rounded bg-red-500" } - span { "Error" } - } - } - for (idx, (trace_id, flat_spans)) in traces.iter().enumerate().take(max_traces) { - {trace_block(trace_id, flat_spans, idx, expanded)} - } - if traces.len() > max_traces { - p { class: "text-sm text-gray-400 text-center py-2", - "Showing {max_traces} of {traces.len()} traces" - } - } - } - } -} - -fn trace_block( - trace_id: &str, - flat_spans: &[FlatSpan], - idx: usize, - expanded: Signal>, -) -> Element { - if flat_spans.is_empty() { - return rsx! {}; - } - - let is_open = expanded.read().contains(&idx); - let trace_start = flat_spans - .iter() - .map(|f| f.span.start_us) - .min() - .unwrap_or(0); - let trace_end = flat_spans.iter().map(|f| f.span.end_us).max().unwrap_or(1); - let trace_range = (trace_end - trace_start).max(1) as f64; - let trace_dur_label = format_duration_us(trace_range); - let root_name = root_label(&flat_spans[0]); - let short_tid = if trace_id.len() > 8 { - &trace_id[..8] - } else { - trace_id - }; - - let max_depth = flat_spans.iter().map(|f| f.depth).max().unwrap_or(0); - let depth_info = if max_depth > 0 { - format!("depth {max_depth}") - } else { - String::new() - }; - - let chevron_class = if is_open { "rotate-90" } else { "rotate-0" }; - - let mut sig = expanded; - - rsx! { - div { class: "border border-gray-200 rounded-lg overflow-hidden", - // Clickable trace header - div { - class: "bg-gray-50 px-4 py-2.5 border-b border-gray-100 flex items-center justify-between cursor-pointer hover:bg-gray-100/80 transition-colors select-none", - onclick: move |_| { - let mut set = sig.write(); - if set.contains(&idx) { - set.remove(&idx); - } else { - set.insert(idx); - } - }, - div { class: "flex items-center gap-2 min-w-0", - // Chevron - svg { - class: "w-3.5 h-3.5 text-gray-400 flex-shrink-0 transition-transform duration-150 {chevron_class}", - fill: "none", - stroke: "currentColor", - view_box: "0 0 24 24", - path { - stroke_linecap: "round", - stroke_linejoin: "round", - stroke_width: "2", - d: "M9 5l7 7-7 7", - } - } - span { class: "text-sm font-semibold text-gray-800 truncate", "{root_name}" } - span { class: "text-xs text-gray-400 font-mono flex-shrink-0", "{short_tid}…" } - } - div { class: "flex items-center gap-3 flex-shrink-0 ml-3", - if !depth_info.is_empty() { - span { class: "text-xs text-gray-400", "{depth_info}" } - } - span { class: "text-xs text-gray-500", "{flat_spans.len()} spans" } - span { class: "text-xs font-medium text-gray-600", "{trace_dur_label}" } - } - } - // Span rows (only if expanded) - if is_open { - div { class: "divide-y divide-gray-100", - for (i, fs) in flat_spans.iter().enumerate() { - {span_row(fs, trace_start, trace_range, i)} - } - } - } - } - } -} - -/// Root label for the trace header. -fn root_label(fs: &FlatSpan) -> String { - if fs.span.actor.is_empty() { - fs.span.name.clone() - } else { - fs.span.actor.clone() - } -} - -/// Row label: "caller → callee" for child spans, just actor for root. -fn row_label(fs: &FlatSpan) -> String { - let callee = if fs.span.actor.is_empty() { - &fs.span.name - } else { - &fs.span.actor - }; - if fs.caller.is_empty() || fs.caller == *callee { - callee.to_string() - } else { - format!("{} → {}", fs.caller, callee) - } -} - -fn span_tooltip(s: &SpanRow, caller: &str, self_time_us: i64) -> String { - let wall = format_duration_us(s.duration_us as f64); - let self_t = format_duration_us(self_time_us as f64); - let start = format_timestamp_us(s.start_us); - let end = format_timestamp_us(s.end_us); - let caller_part = if caller.is_empty() { - "root".to_string() - } else { - format!("caller: {caller}") - }; - format!( - "{}\nspan: {}\n{}\nstart: {} → end: {}\nwall: {} | self: {}\nstatus: {} | kind: {}\nspan_id: {}", - s.actor, s.name, caller_part, start, end, wall, self_t, s.status, s.kind, s.span_id, - ) -} - -fn span_row(fs: &FlatSpan, trace_start: i64, trace_range: f64, idx: usize) -> Element { - let s = &fs.span; - let indent_px = fs.depth * 24; - let left_pct = ((s.start_us - trace_start) as f64 / trace_range * 100.0).max(0.0); - let wall_width_pct = (s.duration_us as f64 / trace_range * 100.0).max(0.5); - let self_ratio = if s.duration_us > 0 { - fs.self_time_us as f64 / s.duration_us as f64 - } else { - 1.0 - }; - let wall_label = format_duration_us(s.duration_us as f64); - let self_label = format_duration_us(fs.self_time_us as f64); - let start_label = format_timestamp_us(s.start_us); - let end_label = format_timestamp_us(s.end_us); - - let (wall_color, self_color) = match s.status.as_str() { - "ok" => ("bg-emerald-200", "bg-emerald-500"), - "error" => ("bg-red-200", "bg-red-500"), - _ => ("bg-blue-200", "bg-blue-500"), - }; - let row_bg = if idx.is_multiple_of(2) { - "bg-white" - } else { - "bg-gray-50/30" - }; - - let label = row_label(fs); - let tooltip = span_tooltip(s, &fs.caller, fs.self_time_us); - - let time_label = if fs.self_time_us < s.duration_us { - format!("{wall_label} (self {self_label})") - } else { - wall_label.clone() - }; - - rsx! { - div { - class: "flex items-center {row_bg} hover:bg-blue-50/40 transition-colors min-h-[34px]", - title: "{tooltip}", - div { - class: "flex-shrink-0 w-[360px] px-3 py-1 text-sm border-r border-gray-100 flex items-center min-w-0", - style: "padding-left: {indent_px + 12}px", - if fs.depth > 0 { - span { class: "text-gray-300 mr-1.5 flex-shrink-0", "└" } - } - span { - class: "font-medium text-gray-700 truncate", - title: "{label}", - "{label}" - } - } - div { class: "flex-1 px-2 py-1 relative h-[28px]", - // Wall time bar (lighter) - div { - class: "absolute top-1 h-[20px] rounded {wall_color} cursor-default", - style: "left: {left_pct:.2}%; width: {wall_width_pct:.2}%;", - title: "{s.name}\n{start_label} → {end_label}\nwall: {wall_label} | self: {self_label}\n[{s.status}]", - } - // Self time bar (solid, overlaid from left edge) - div { - class: "absolute top-1 h-[20px] rounded-l {self_color} pointer-events-none", - style: "left: {left_pct:.2}%; width: calc({wall_width_pct:.2}% * {self_ratio:.4});", - } - // Duration label - div { - class: "absolute top-1 h-[20px] flex items-center text-[10px] text-gray-500 pointer-events-none whitespace-nowrap", - style: "left: calc({left_pct:.2}% + {wall_width_pct:.2}% + 4px);", - "{time_label}" - } - } - } - } -} diff --git a/web/src/pages/python/active.rs b/web/src/pages/python/active.rs deleted file mode 100644 index a2e71900..00000000 --- a/web/src/pages/python/active.rs +++ /dev/null @@ -1,216 +0,0 @@ -use std::collections::HashSet; - -use dioxus::prelude::*; - -use crate::api::{ApiClient, VariableRecord}; -use crate::components::colors::colors; -use crate::components::common::{query_result, AppErrorDisplay}; -use crate::hooks::use_app_resource; -use crate::utils::error::AppError; - -use super::shared::PREVIEW_RECORD_LIMIT; - -#[component] -pub fn ActiveTracesPanel( - poll: Signal, - refresh_key: Signal, - on_view_records: EventHandler, -) -> Element { - let traces = use_app_resource(move || { - let _ = poll(); - let _ = refresh_key(); - async move { ApiClient::new().get_trace_info().await } - }); - let mut stop_trace = use_action(move |func: String| async move { - ApiClient::new().stop_trace(&func).await?; - refresh_key.set(refresh_key() + 1); - Ok::<(), AppError>(()) - }); - - let trace_list = traces.suspend()?(); - - query_result( - trace_list, - |list| list.is_empty(), - "No active traces. Pick a function from the catalog and click Trace.", - move |active| { - rsx! { - div { class: "divide-y divide-gray-100", - for func in active { - ActiveTraceRow { - key: "{func}", - function: func, - poll, - refresh_key, - on_view_records, - stop_pending: stop_trace.pending(), - on_stop: move |name| stop_trace.call(name), - } - } - } - if stop_trace.pending() { - div { class: "px-4 py-2 text-xs text-gray-500 border-t border-gray-100", - "Stopping trace…" - } - } else if let Some(Err(err)) = stop_trace.value() { - div { class: "px-4 py-2 border-t border-gray-100", - AppErrorDisplay { - error: AppError::Api(err.to_string()), - title: Some("Stop failed".to_string()), - } - } - } - } - }, - ) -} - -#[component] -fn ActiveTraceRow( - function: String, - poll: Signal, - refresh_key: Signal, - on_view_records: EventHandler, - stop_pending: bool, - on_stop: EventHandler, -) -> Element { - let records = use_app_resource({ - let function = function.clone(); - move || { - let func = function.clone(); - let _ = poll(); - let _ = refresh_key(); - async move { fetch_trace_preview(&func).await } - } - }); - let snapshot = records.read(); - let refreshing = records.pending(); - let latest = snapshot - .as_ref() - .and_then(|r| r.as_ref().ok()) - .map(|rows| latest_from_records(rows)) - .unwrap_or_default(); - - rsx! { - div { class: "px-4 py-3 hover:bg-gray-50/80", - div { class: "flex items-start gap-3", - div { class: "min-w-0 flex-1", - span { class: "inline-flex items-center gap-1.5 text-xs font-medium text-emerald-800 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full mb-1", - span { class: "w-1.5 h-1.5 rounded-full bg-emerald-500" } - "tracing" - } - p { class: "font-mono text-sm text-gray-900 break-all", "{function}" } - if let Some(result) = snapshot.as_ref() { - match result { - Ok(_) if latest.is_empty() => rsx! { - p { class: "mt-1.5 text-xs text-gray-400", "Waiting for variable updates…" } - }, - Ok(_) => rsx! { - div { class: "mt-2 space-y-1.5", - div { class: "flex flex-wrap gap-1.5", - for (name, value, ty) in latest { - VariablePreviewChip { name, value, ty } - } - } - if refreshing { - p { class: "text-[11px] text-gray-400", "Updating…" } - } - } - }, - Err(_) => rsx! { - p { class: "mt-1.5 text-xs text-amber-700", "Preview unavailable" } - }, - } - } else { - p { class: "mt-1.5 text-xs text-gray-400", "Loading latest values…" } - } - } - div { class: "flex shrink-0 gap-2 pt-0.5", - button { - class: "px-2.5 py-1.5 text-xs rounded-md border border-gray-300 bg-white hover:bg-gray-50", - onclick: { - let func = function.clone(); - move |_| on_view_records.call(func.clone()) - }, - "Records" - } - button { - class: format!( - "px-2.5 py-1.5 text-xs rounded-md text-white bg-{} hover:bg-{} disabled:opacity-50", - colors::ERROR, - colors::ERROR_HOVER, - ), - disabled: stop_pending, - onclick: { - let func = function.clone(); - move |_| on_stop.call(func.clone()) - }, - "Stop" - } - } - } - } - } -} - -#[component] -fn VariablePreviewChip(name: String, value: String, ty: String) -> Element { - rsx! { - span { - class: format!( - "inline-flex items-center gap-1 max-w-full text-xs font-mono px-2 py-1 rounded border bg-{} border-{}", - colors::CONTENT_ACCENT_BG, - colors::CONTENT_ACCENT_BORDER, - ), - title: "{name} = {value} ({ty})", - span { class: "text-gray-600 shrink-0", "{name}" } - span { class: "text-gray-400 shrink-0", "=" } - span { class: "text-gray-900 truncate max-w-[10rem]", "{value}" } - span { class: "text-[10px] text-gray-400 shrink-0", "{ty}" } - } - } -} - -async fn fetch_trace_preview(function: &str) -> Result, AppError> { - let client = ApiClient::new(); - let rows = client - .get_trace_variables(Some(function), Some(PREVIEW_RECORD_LIMIT)) - .await?; - if !rows.is_empty() { - return Ok(rows); - } - let all = client - .get_trace_variables(None, Some(PREVIEW_RECORD_LIMIT)) - .await?; - Ok(filter_records_for_function(all, function)) -} - -fn filter_records_for_function( - records: Vec, - function: &str, -) -> Vec { - let short = function.rsplit('.').next().unwrap_or(function); - records - .into_iter() - .filter(|r| { - r.function_name == function - || r.function_name == short - || r.function_name.ends_with(&format!(".{short}")) - }) - .collect() -} - -fn latest_from_records(records: &[VariableRecord]) -> Vec<(String, String, String)> { - let mut seen = HashSet::new(); - let mut out = Vec::new(); - for r in records { - if seen.insert(r.variable_name.clone()) { - out.push(( - r.variable_name.clone(), - r.value.clone(), - r.value_type.clone(), - )); - } - } - out -} diff --git a/web/src/pages/python/catalog.rs b/web/src/pages/python/catalog.rs deleted file mode 100644 index d14b8001..00000000 --- a/web/src/pages/python/catalog.rs +++ /dev/null @@ -1,194 +0,0 @@ -use dioxus::prelude::*; - -use crate::api::{ApiClient, TraceableItem}; -use crate::components::colors::colors; -use crate::components::common::{query_result, EmptyState}; -use crate::components::icon::Icon; -use crate::hooks::use_app_resource; - -#[component] -pub fn TraceableCatalog(on_start: EventHandler<(String, Vec)>) -> Element { - let mut filter = use_signal(String::new); - let items = - use_app_resource(|| async move { ApiClient::new().get_traceable_items(None).await }); - let list = items.suspend()?(); - - query_result( - list, - |items| items.is_empty(), - "No traceable functions found in the target process.", - move |items| { - let filtered = filter_traceables(&items, &filter()); - rsx! { - div { class: "border-b border-gray-200 px-3 py-2.5 bg-gray-50/80", - div { class: "relative", - span { class: "absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none", - Icon { icon: &icondata::AiSearchOutlined, class: "w-4 h-4" } - } - input { - r#type: "text", - class: "w-full pl-8 pr-3 py-2 text-sm rounded-md border border-gray-300 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500", - placeholder: "Filter module or function…", - value: "{filter}", - oninput: move |ev| filter.set(ev.value()), - } - } - p { class: "mt-1.5 text-xs text-gray-500", - "{filtered.len()} of {items.len()} items · expand modules · Trace or pick variables" - } - } - if filtered.is_empty() { - div { class: "px-4 py-10", - EmptyState { message: format!("No items match \"{}\"", filter()) } - } - } else { - div { class: "p-2 space-y-1 max-h-[calc(100vh-14rem)] overflow-y-auto", - for item in filtered { - TraceableRow { - key: "{item.name}", - item: item.clone(), - on_start, - } - } - } - } - } - }, - ) -} - -fn filter_traceables(items: &[TraceableItem], query: &str) -> Vec { - let q = query.trim().to_lowercase(); - if q.is_empty() { - return items.to_vec(); - } - items - .iter() - .filter(|item| { - item.name.to_lowercase().contains(&q) - || item.variables.iter().any(|v| v.to_lowercase().contains(&q)) - }) - .cloned() - .collect() -} - -#[component] -fn TraceableRow(item: TraceableItem, on_start: EventHandler<(String, Vec)>) -> Element { - let is_module = item.item_type == "M"; - let mut expanded = use_signal(|| false); - - rsx! { - div { class: "rounded-lg border border-gray-200 bg-white overflow-hidden", - div { class: "flex items-start gap-2 px-3 py-2.5", - if is_module { - button { - class: "mt-0.5 shrink-0 p-0.5 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100", - onclick: move |_| expanded.set(!expanded()), - if expanded() { - Icon { icon: &icondata::AiCaretDownOutlined, class: "w-3.5 h-3.5" } - } else { - Icon { icon: &icondata::AiCaretRightOutlined, class: "w-3.5 h-3.5" } - } - } - } else { - span { class: "w-4 shrink-0" } - } - div { class: "min-w-0 flex-1 space-y-2", - div { class: "flex flex-wrap items-center gap-2", - TypeBadge { item_type: item.item_type.clone() } - span { class: "font-mono text-sm text-gray-900 break-all", "{item.name}" } - if !is_module { - button { - class: format!( - "ml-auto shrink-0 px-2.5 py-1 text-xs rounded-md text-white bg-{} hover:bg-{}", - colors::PRIMARY, - colors::PRIMARY_HOVER, - ), - onclick: { - let name = item.name.clone(); - let vars = item.variables.clone(); - move |_| on_start.call((name.clone(), vars.clone())) - }, - "Trace" - } - } - } - if !is_module && !item.variables.is_empty() { - div { class: "flex flex-wrap gap-1.5", - for var in item.variables.iter() { - { - let v = var.clone(); - let name = item.name.clone(); - rsx! { - button { - class: format!( - "text-[11px] px-2 py-0.5 rounded border bg-{} text-{} border-{} hover:bg-blue-100 font-mono", - colors::CONTENT_ACCENT_BG, - colors::CONTENT_ACCENT_TEXT, - colors::CONTENT_ACCENT_BORDER, - ), - title: "Start tracing this variable", - onclick: move |_| on_start.call((name.clone(), vec![v.clone()])), - "{v}" - } - } - } - } - } - } else if !is_module { - p { class: "text-[11px] text-gray-400", "No traceable locals reported" } - } - } - } - if is_module && expanded() { - ModuleChildren { - prefix: item.name.clone(), - on_start, - } - } - } - } -} - -#[component] -fn ModuleChildren(prefix: String, on_start: EventHandler<(String, Vec)>) -> Element { - let children = use_app_resource(move || { - let p = prefix.clone(); - async move { ApiClient::new().get_traceable_items(Some(&p)).await } - }); - let list = children.suspend()?(); - - query_result( - list, - |items| items.is_empty(), - "Empty module", - move |items| { - rsx! { - div { class: "border-t border-gray-100 bg-gray-50/50 p-2 space-y-1", - for child in items { - TraceableRow { - key: "{child.name}", - item: child.clone(), - on_start, - } - } - } - } - }, - ) -} - -#[component] -fn TypeBadge(item_type: String) -> Element { - let (label, class) = match item_type.as_str() { - "F" => ("fn", "bg-blue-50 text-blue-800 border-blue-200"), - "M" => ("mod", "bg-emerald-50 text-emerald-800 border-emerald-200"), - other => (other, "bg-gray-100 text-gray-700 border-gray-200"), - }; - rsx! { - span { - class: "shrink-0 text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border {class}", - "{label}" - } - } -} diff --git a/web/src/pages/python/dialogs.rs b/web/src/pages/python/dialogs.rs deleted file mode 100644 index 2505b426..00000000 --- a/web/src/pages/python/dialogs.rs +++ /dev/null @@ -1,220 +0,0 @@ -use dioxus::html::events::KeyboardEvent; -use dioxus::html::input_data::keyboard_types::Key; -use dioxus::prelude::*; - -use crate::api::ApiClient; -use crate::components::colors::colors; -use crate::components::common::{AppErrorDisplay, LoadingState}; -use crate::components::dataframe_view::DataFrameView; -use crate::components::icon::Icon; -use crate::hooks::use_app_resource; -use crate::utils::error::AppError; - -use super::shared::{StartTraceDraft, POLL_MS}; - -#[component] -pub fn StartTraceDialog( - draft: Signal, - on_close: EventHandler<()>, - on_started: EventHandler<()>, -) -> Element { - #[allow(clippy::redundant_closure)] - let mut local = use_signal(|| draft()); - let mut start_trace = use_action( - move |(function, watch, print_to_terminal): (String, String, bool)| async move { - let watch_list: Vec = if watch.trim().is_empty() { - vec![] - } else { - watch - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() - }; - let resp = ApiClient::new() - .start_trace(&function, Some(watch_list), print_to_terminal) - .await?; - if resp.success { - on_started.call(()); - Ok(()) - } else { - Err(AppError::Api( - resp.error - .or(resp.message) - .unwrap_or_else(|| "Start trace failed".to_string()), - )) - } - }, - ); - - rsx! { - div { - class: "fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4", - tabindex: "-1", - onkeydown: move |e: KeyboardEvent| { - if e.key() == Key::Escape && !start_trace.pending() { - on_close.call(()); - } - }, - div { - class: "absolute inset-0 bg-slate-900/40 backdrop-blur-sm", - onclick: move |_| { - if !start_trace.pending() { - on_close.call(()); - } - }, - } - div { - class: "relative w-full sm:max-w-md bg-white sm:rounded-xl shadow-2xl border border-gray-200 p-5", - onclick: move |e| e.stop_propagation(), - div { class: "flex items-center gap-2 mb-4", - Icon { icon: &icondata::SiPython, class: "w-5 h-5 text-blue-600" } - h3 { class: "text-base font-semibold text-gray-900", "Start tracing" } - } - div { class: "space-y-4", - div { - label { class: "block text-xs font-medium text-gray-600 mb-1", "Function" } - input { - class: "w-full px-3 py-2 text-sm font-mono border border-gray-200 rounded-md bg-gray-50", - readonly: true, - value: "{local().function}", - } - } - div { - label { class: "block text-xs font-medium text-gray-600 mb-1", - "Watch variables (comma-separated)" - } - input { - class: "w-full px-3 py-2 text-sm font-mono border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500/30", - placeholder: "x, y, loss", - value: "{local().watch}", - oninput: move |ev| { - local.write().watch = ev.value(); - }, - } - } - label { class: "flex items-center gap-2 text-sm text-gray-700 cursor-pointer", - input { - r#type: "checkbox", - class: "rounded border-gray-300", - checked: local().print_to_terminal, - onchange: move |ev| { - local.write().print_to_terminal = ev.checked(); - }, - } - "Print changes to terminal (otherwise DB only)" - } - if start_trace.pending() { - LoadingState { message: Some("Starting trace…".to_string()) } - } else if let Some(Err(err)) = start_trace.value() { - AppErrorDisplay { - error: AppError::Api(err.to_string()), - title: Some("Start failed".to_string()), - } - } - div { class: "flex justify-end gap-2 pt-2", - button { - class: "px-3 py-2 text-sm rounded-md border border-gray-300 hover:bg-gray-50 disabled:opacity-50", - disabled: start_trace.pending(), - onclick: move |_| on_close.call(()), - "Cancel" - } - button { - class: format!( - "px-4 py-2 text-sm rounded-md text-white bg-{} hover:bg-{} disabled:opacity-50", - colors::PRIMARY, - colors::PRIMARY_HOVER, - ), - disabled: start_trace.pending(), - onclick: move |_| { - let d = local(); - start_trace.call((d.function, d.watch, d.print_to_terminal)); - }, - if start_trace.pending() { "Starting…" } else { "Start" } - } - } - } - } - } - } -} - -#[component] -pub fn RecordsModal(function: String, poll: Signal, on_close: EventHandler<()>) -> Element { - let function_label = function.clone(); - let records = use_app_resource({ - let function = function.clone(); - move || { - let func = function.clone(); - let _ = poll(); - async move { - ApiClient::new() - .get_variable_records(Some(&func), Some(100)) - .await - } - } - }); - let refreshing = records.pending(); - let snapshot = records.read(); - - rsx! { - div { - class: "fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4", - tabindex: "-1", - onkeydown: move |e: KeyboardEvent| { - if e.key() == Key::Escape { - on_close.call(()); - } - }, - div { - class: "absolute inset-0 bg-slate-900/40 backdrop-blur-sm", - onclick: move |_| on_close.call(()), - } - div { - class: "relative w-full sm:max-w-5xl max-h-[90vh] flex flex-col bg-white sm:rounded-xl shadow-2xl border border-gray-200 overflow-hidden", - div { class: "flex items-center justify-between gap-3 px-4 py-3 border-b border-gray-200 bg-gray-50/80", - div { class: "min-w-0", - h3 { class: "text-base font-semibold text-gray-900 truncate", "Variable records" } - p { class: "text-xs font-mono text-gray-500 truncate", "{function_label}" } - } - div { class: "flex items-center gap-2 shrink-0", - if refreshing && snapshot.as_ref().is_some() { - span { class: "text-[11px] text-gray-500", "Updating…" } - } - span { class: "text-[11px] text-gray-500 tabular-nums", - "Auto {POLL_MS / 1000}s" - } - button { - class: format!( - "px-3 py-1.5 text-sm rounded-md bg-{} hover:bg-{}", - colors::BTN_SECONDARY_BG, - colors::BTN_SECONDARY_HOVER, - ), - onclick: move |_| on_close.call(()), - "Close" - } - } - } - div { class: "flex-1 overflow-auto p-4", - if let Some(result) = snapshot.as_ref() { - match result { - Ok(df) => rsx! { - div { class: "rounded-lg border border-gray-200 overflow-hidden", - DataFrameView { df: df.clone(), on_row_click: None } - } - }, - Err(err) => rsx! { - AppErrorDisplay { - error: err.clone(), - title: Some("Load failed".to_string()), - } - }, - } - } else { - LoadingState { message: Some("Loading records…".to_string()) } - } - } - } - } - } -} diff --git a/web/src/pages/python/mod.rs b/web/src/pages/python/mod.rs deleted file mode 100644 index a8fed898..00000000 --- a/web/src/pages/python/mod.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Python variable tracing page (`api/trace` — live function watch, not distributed spans). - -mod active; -mod catalog; -mod dialogs; -mod shared; - -use dioxus::prelude::*; - -use crate::components::card::Card; -use crate::components::common::AsyncBoundary; -use crate::components::page::{PageContainer, PageTitle}; -use crate::components::poll_status::PollStatusBar; -use crate::hooks::{use_page_visible, use_poll_tick_gated}; - -use active::ActiveTracesPanel; -use catalog::TraceableCatalog; -use dialogs::{RecordsModal, StartTraceDialog}; -use shared::{RefreshButton, StartTraceDraft, POLL_MS}; - -#[component] -pub fn Python() -> Element { - let visible = use_page_visible(); - let poll = use_poll_tick_gated(POLL_MS, Some(visible)); - let poll_tick = poll(); - let mut refresh_key = use_signal(|| 0u32); - let mut start_open = use_signal(|| false); - let mut start_draft = use_signal(|| StartTraceDraft { - function: String::new(), - watch: String::new(), - print_to_terminal: false, - }); - let mut records_open = use_signal(|| false); - let mut records_function = use_signal(String::new); - - let open_records = EventHandler::new(move |func: String| { - records_function.set(func); - records_open.set(true); - }); - - let open_start_dialog = EventHandler::new(move |args: (String, Vec)| { - let (function, watch_vars) = args; - start_draft.set(StartTraceDraft { - function, - watch: watch_vars.join(", "), - print_to_terminal: false, - }); - start_open.set(true); - }); - - rsx! { - PageContainer { - PageTitle { - title: "Python variable tracing".to_string(), - subtitle: Some(format!( - "Watch live function variables — not Spans or Profiling chrome trace · auto refresh every {}s while tab is visible", - POLL_MS / 1000 - )), - icon: Some(&icondata::SiPython), - header_right: Some(rsx! { - PollStatusBar { - interval_secs: POLL_MS / 1000, - poll_tick, - } - }), - } - - div { class: "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start", - div { class: "lg:col-span-5 min-w-0", - Card { - title: "Active watches", - content_class: Some("p-0"), - header_right: Some(rsx! { - RefreshButton { - onclick: move |_| refresh_key.set(refresh_key() + 1), - } - }), - AsyncBoundary { - message: Some("Loading active traces…".to_string()), - ActiveTracesPanel { - poll, - refresh_key, - on_view_records: open_records, - } - } - } - } - - div { class: "lg:col-span-7 min-w-0", - Card { - title: "Traceable Catalog", - content_class: Some("p-0"), - AsyncBoundary { - message: Some("Loading traceable items…".to_string()), - TraceableCatalog { - on_start: open_start_dialog, - } - } - } - } - } - - if *start_open.read() { - StartTraceDialog { - draft: start_draft, - on_close: move |_| start_open.set(false), - on_started: move |_| { - refresh_key.set(refresh_key() + 1); - start_open.set(false); - }, - } - } - - if *records_open.read() { - RecordsModal { - function: records_function(), - poll, - on_close: move |_| records_open.set(false), - } - } - } - } -} diff --git a/web/src/pages/python/shared.rs b/web/src/pages/python/shared.rs deleted file mode 100644 index 56a5564d..00000000 --- a/web/src/pages/python/shared.rs +++ /dev/null @@ -1,34 +0,0 @@ -use dioxus::prelude::*; - -use crate::components::icon::Icon; - -pub const POLL_MS: u32 = 3000; -pub const PREVIEW_RECORD_LIMIT: usize = 50; - -#[derive(Clone, PartialEq)] -pub struct StartTraceDraft { - pub function: String, - pub watch: String, - pub print_to_terminal: bool, -} - -#[component] -pub fn PollHint(interval_secs: u32) -> Element { - rsx! { - span { class: "text-[11px] text-gray-500 tabular-nums", - "Auto {interval_secs}s" - } - } -} - -#[component] -pub fn RefreshButton(onclick: EventHandler<()>) -> Element { - rsx! { - button { - class: "inline-flex items-center gap-1 px-2 py-1.5 text-xs rounded-md border border-gray-300 bg-white hover:bg-gray-50", - onclick: move |_| onclick.call(()), - Icon { icon: &icondata::AiReloadOutlined, class: "w-3.5 h-3.5" } - "Refresh" - } - } -} diff --git a/web/src/pages/rl/inference.rs b/web/src/pages/rl/inference.rs deleted file mode 100644 index 03964adc..00000000 --- a/web/src/pages/rl/inference.rs +++ /dev/null @@ -1,467 +0,0 @@ -use std::collections::HashMap; - -use dioxus::prelude::*; -use probing_proto::prelude::{DataFrame, Ele}; - -use crate::api::ApiClient; -use crate::api::EngineInfo; -use crate::components::card::Card; -use crate::components::common::{EmptyState, ErrorState, LoadingState}; -use crate::components::page::{PageContainer, PageTitle}; -use crate::components::rl::metrics_line_chart::{ChartSeries, MetricsLineChart}; -use crate::hooks::use_api; -use crate::state::inference::INFERENCE_REFRESH; - -const REFRESH_MS: u32 = 5000; -const METRICS_HISTORY_LIMIT: i64 = 500; - -const METRIC_DEFS: [(&str, &str); 7] = [ - ("normalized.inflight_requests", "In-flight requests"), - ("normalized.queue_depth", "Queue depth"), - ("normalized.throughput_tps", "Throughput (tok/s)"), - ("normalized.tpot_ms", "TPOT (ms)"), - ("normalized.ttft_ms", "TTFT (ms)"), - ("normalized.kv_cache_usage_ratio", "KV cache usage"), - ("normalized.cache_hit_ratio", "Cache hit ratio"), -]; - -const SERIES_COLORS: [&str; 6] = [ - "#2563eb", "#dc2626", "#16a34a", "#9333ea", "#ea580c", "#0891b2", -]; - -#[component] -pub fn Inference(#[props(default = true)] show_controls: bool) -> Element { - let engines = use_api(move || { - let _ = *INFERENCE_REFRESH.read(); - let client = ApiClient::new(); - async move { - client - .fetch_inference_engines() - .await - .map(|resp| resp.engines) - } - }); - let metrics = use_api(move || { - let _ = *INFERENCE_REFRESH.read(); - let client = ApiClient::new(); - async move { - client - .fetch_inference_engine_metrics(METRICS_HISTORY_LIMIT) - .await - } - }); - - use_effect(move || { - spawn(async move { - loop { - gloo_timers::future::TimeoutFuture::new(REFRESH_MS).await; - *INFERENCE_REFRESH.write() += 1; - } - }); - }); - - rsx! { - PageContainer { - PageTitle { - title: "Inference Engine".to_string(), - subtitle: Some( - "Inference-engine Prometheus metrics (scrape path configurable, e.g. /metrics)".to_string(), - ), - icon: Some(&icondata::AiDashboardOutlined), - } - - if show_controls { - div { class: "flex items-center gap-3 mb-4", - button { - class: "px-3 py-1.5 rounded-md text-sm bg-blue-600 text-white hover:bg-blue-700", - onclick: move |_| { - spawn(async move { - let _ = ApiClient::new().scrape_inference_engines().await; - *INFERENCE_REFRESH.write() += 1; - }); - }, - "Scrape now" - } - span { class: "text-xs text-slate-500", "Auto refresh every {REFRESH_MS / 1000}s" } - } - } - - Card { - title: "Registered engines", - content_class: Some(""), - {engines_panel(&engines)} - } - - Card { - title: "Latest normalized metrics", - content_class: Some(""), - {metrics_cards(&engines)} - } - - Card { - title: "Metric trends (time series)", - content_class: Some(""), - {metrics_charts(&metrics)} - } - } - } -} - -fn engines_panel(state: &crate::hooks::ApiState>) -> Element { - if state.is_loading() && state.data.read().is_none() { - return rsx! { LoadingState { message: Some("Loading engines...".to_string()) } }; - } - match state.data.read().as_ref() { - Some(Ok(engines)) if engines.is_empty() => rsx! { - EmptyState { - message: "No inference engine registered. Register via /apis/pythonext/engines/register?router_addr=http://host:port&metrics_path=/metrics".to_string(), - } - }, - Some(Ok(engines)) => rsx! { - div { class: "overflow-x-auto", - table { class: "min-w-full text-sm", - thead { - tr { class: "text-left text-slate-500 border-b border-slate-200", - th { class: "py-2 pr-4", "Engine" } - th { class: "py-2 pr-4", "Type" } - th { class: "py-2 pr-4", "Framework" } - th { class: "py-2 pr-4", "Router" } - th { class: "py-2 pr-4", "Metrics URL" } - th { class: "py-2 pr-4", "Status" } - } - } - tbody { - for engine in engines.iter() { - tr { class: "border-b border-slate-100", - td { class: "py-2 pr-4 font-medium", "{engine.engine_id}" } - td { class: "py-2 pr-4", "{engine.engine_type}" } - td { class: "py-2 pr-4", "{engine.framework}" } - td { class: "py-2 pr-4 font-mono text-xs break-all", "{engine.router_addr}" } - td { class: "py-2 pr-4 font-mono text-xs break-all", "{engine.metrics_url}" } - td { class: "py-2 pr-4", - span { - class: if engine.status == "healthy" { - "text-emerald-600" - } else { - "text-amber-600" - }, - "{engine.status}" - } - if let Some(error) = &engine.last_scrape_error { - div { class: "text-xs text-red-500 mt-1", "{error}" } - } - } - } - } - } - } - } - }, - Some(Err(error)) => rsx! { ErrorState { error: error.to_string(), title: None } }, - None => rsx! { LoadingState { message: Some("Loading engines...".to_string()) } }, - } -} - -fn metrics_cards(state: &crate::hooks::ApiState>) -> Element { - if state.is_loading() && state.data.read().is_none() { - return rsx! { LoadingState { message: Some("Loading metrics...".to_string()) } }; - } - let data = state.data.read(); - let Some(Ok(engines)) = data.as_ref() else { - return rsx! { EmptyState { message: "No metrics yet.".to_string() } }; - }; - if engines.is_empty() { - return rsx! { EmptyState { message: "Register an engine to see metrics.".to_string() } }; - } - - let labels = [ - ("inflight_requests", "In-flight requests"), - ("queue_depth", "Queue depth"), - ("throughput_tps", "Throughput (tok/s)"), - ("tpot_ms", "TPOT (ms)"), - ("ttft_ms", "TTFT (ms)"), - ("kv_cache_usage_ratio", "KV cache usage"), - ("cache_hit_ratio", "Cache hit ratio"), - ]; - - rsx! { - for engine in engines.iter() { - div { class: "mb-4", - div { class: "text-sm font-semibold text-slate-700 mb-2", "{engine.engine_id}" } - div { class: "grid grid-cols-2 md:grid-cols-4 gap-3", - for (key, label) in labels.iter() { - div { class: "rounded-lg border border-slate-200 bg-slate-50 px-3 py-2", - div { class: "text-[11px] uppercase tracking-wide text-slate-500", "{label}" } - div { class: "text-lg font-semibold text-slate-900", - { - engine.last_normalized - .get(*key) - .map(format_metric_value) - .unwrap_or_else(|| "—".to_string()) - } - } - } - } - } - } - } - } -} - -fn metrics_charts(state: &crate::hooks::ApiState) -> Element { - if state.is_loading() && state.data.read().is_none() { - return rsx! { LoadingState { message: Some("Loading metric trends...".to_string()) } }; - } - match state.data.read().as_ref() { - Some(Ok(df)) if df.is_empty() => rsx! { - EmptyState { - message: "No metric samples stored yet. Click Scrape now or wait for the background scraper.".to_string(), - } - }, - Some(Ok(df)) => { - let grouped = group_metric_rows(df); - if grouped.is_empty() { - return rsx! { - EmptyState { - message: "No normalized metric samples found yet.".to_string(), - } - }; - } - let engine_colors = engine_color_map(&grouped); - - rsx! { - div { class: "grid grid-cols-1 xl:grid-cols-2 gap-4", - for (metric_key, title) in METRIC_DEFS.iter() { - { - let chart_series = build_chart_series(&grouped, &engine_colors, metric_key); - rsx! { - MetricsLineChart { - title: title.to_string(), - series: chart_series, - } - } - } - } - } - } - } - Some(Err(error)) => rsx! { ErrorState { error: error.to_string(), title: None } }, - None => rsx! { LoadingState { message: Some("Loading metric trends...".to_string()) } }, - } -} - -fn group_metric_rows(df: &DataFrame) -> HashMap<(String, String), Vec<(f64, f64)>> { - let ts_idx = col_idx(df, "timestamp_ns"); - let engine_idx = col_idx(df, "engine_id"); - let name_idx = col_idx(df, "metric_name"); - let value_idx = col_idx(df, "metric_value"); - - let Some((ts_idx, engine_idx, name_idx, value_idx)) = - (match (ts_idx, engine_idx, name_idx, value_idx) { - (Some(a), Some(b), Some(c), Some(d)) => Some((a, b, c, d)), - _ => None, - }) - else { - return HashMap::new(); - }; - - let mut grouped: HashMap<(String, String), Vec<(f64, f64)>> = HashMap::new(); - for row in df.iter() { - let Some(ts_ns) = ele_i64(row.get(ts_idx)) else { - continue; - }; - let Some(engine_id) = ele_str(row.get(engine_idx)) else { - continue; - }; - let Some(metric_name) = ele_str(row.get(name_idx)) else { - continue; - }; - let Some(metric_value) = ele_f64(row.get(value_idx)) else { - continue; - }; - if !metric_name.starts_with("normalized.") { - continue; - } - - let ts_ms = ts_ns as f64 / 1_000_000.0; - grouped - .entry((metric_name, engine_id)) - .or_default() - .push((ts_ms, metric_value)); - } - - for points in grouped.values_mut() { - points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); - dedupe_time_points(points); - } - - grouped -} - -fn build_chart_series( - grouped: &HashMap<(String, String), Vec<(f64, f64)>>, - engine_colors: &HashMap, - metric_key: &str, -) -> Vec { - let mut engine_ids: Vec = grouped - .keys() - .filter_map(|(name, engine_id)| (name == metric_key).then_some(engine_id.clone())) - .collect(); - engine_ids.sort(); - - engine_ids - .into_iter() - .filter_map(|engine_id| { - grouped - .get(&(metric_key.to_string(), engine_id.clone())) - .map(|points| ChartSeries { - label: engine_id.clone(), - points: points.clone(), - color: engine_colors.get(&engine_id).copied().unwrap_or("#64748b"), - }) - }) - .collect() -} - -fn engine_color_map( - grouped: &HashMap<(String, String), Vec<(f64, f64)>>, -) -> HashMap { - let mut engines: Vec = grouped - .keys() - .map(|(_, engine_id)| engine_id.clone()) - .collect(); - engines.sort(); - engines.dedup(); - - engines - .into_iter() - .enumerate() - .map(|(idx, engine_id)| (engine_id, SERIES_COLORS[idx % SERIES_COLORS.len()])) - .collect() -} - -fn dedupe_time_points(points: &mut Vec<(f64, f64)>) { - if points.len() <= 1 { - return; - } - let mut deduped: Vec<(f64, f64)> = Vec::with_capacity(points.len()); - for (ts, value) in points.drain(..) { - if deduped - .last() - .map(|(last_ts, _)| (*last_ts - ts).abs() < f64::EPSILON) - == Some(true) - { - if let Some(last) = deduped.last_mut() { - last.1 = value; - } - } else { - deduped.push((ts, value)); - } - } - *points = deduped; -} - -fn col_idx(df: &DataFrame, name: &str) -> Option { - df.names.iter().position(|col| col == name) -} - -fn ele_i64(ele: Option<&Ele>) -> Option { - match ele? { - Ele::I64(v) => Some(*v), - Ele::I32(v) => Some(*v as i64), - Ele::F64(v) => Some(*v as i64), - Ele::F32(v) => Some(*v as i64), - Ele::Text(v) | Ele::Url(v) => v.parse().ok(), - Ele::DataTime(v) => i64::try_from(*v).ok(), - _ => None, - } -} - -fn ele_f64(ele: Option<&Ele>) -> Option { - match ele? { - Ele::F64(v) => Some(*v), - Ele::F32(v) => Some(*v as f64), - Ele::I64(v) => Some(*v as f64), - Ele::I32(v) => Some(*v as f64), - Ele::Text(v) | Ele::Url(v) => v.parse().ok(), - Ele::DataTime(v) => Some(*v as f64), - _ => None, - } -} - -fn ele_str(ele: Option<&Ele>) -> Option { - match ele? { - Ele::Text(v) => Some(v.clone()), - Ele::Url(v) => Some(v.clone()), - Ele::I64(v) => Some(v.to_string()), - Ele::I32(v) => Some(v.to_string()), - Ele::F32(v) => Some(v.to_string()), - Ele::F64(v) => Some(v.to_string()), - _ => None, - } -} - -fn format_metric_value(value: &serde_json::Value) -> String { - match value { - serde_json::Value::Number(number) => { - if let Some(value) = number.as_f64() { - if value.fract() == 0.0 { - format!("{value:.0}") - } else { - format!("{value:.3}") - } - } else { - number.to_string() - } - } - other => other.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use probing_proto::prelude::{DataFrame, Ele, Seq}; - - #[test] - fn group_metric_rows_accepts_text_metric_values_and_numeric_engine_ids() { - let df = DataFrame { - names: vec![ - "timestamp_ns".into(), - "engine_id".into(), - "engine_type".into(), - "metric_name".into(), - "metric_value".into(), - "labels".into(), - ], - cols: vec![ - Seq::SeqI64(vec![1_000_000_000, 2_000_000_000]), - Seq::SeqI64(vec![0, 0]), - Seq::SeqText(vec!["inference".into(), "inference".into()]), - Seq::SeqText(vec![ - "normalized.inflight_requests".into(), - "normalized.inflight_requests".into(), - ]), - Seq::SeqText(vec!["0".into(), "1".into()]), - Seq::SeqText(vec!["normalized=1".into(), "normalized=1".into()]), - ], - size: 0, - }; - - let grouped = group_metric_rows(&df); - assert_eq!(grouped.len(), 1); - let points = grouped - .get(&("normalized.inflight_requests".to_string(), "0".to_string())) - .expect("series"); - assert_eq!(points.len(), 2); - assert_eq!(points[0].1, 0.0); - assert_eq!(points[1].1, 1.0); - } - - #[test] - fn ele_helpers_coerce_common_query_variants() { - assert_eq!(ele_f64(Some(&Ele::Text("0.5".into()))), Some(0.5)); - assert_eq!(ele_str(Some(&Ele::I64(42))), Some("42".into())); - assert_eq!(ele_i64(Some(&Ele::Text("99".into()))), Some(99)); - } -} diff --git a/web/src/pages/rl/mod.rs b/web/src/pages/rl/mod.rs deleted file mode 100644 index ea045d7b..00000000 --- a/web/src/pages/rl/mod.rs +++ /dev/null @@ -1,2872 +0,0 @@ -use dioxus::prelude::*; -use dioxus_router::use_navigator; - -use crate::api::{ApiClient, EventInfo, SpanInfo, TraceProcessInfo}; -use crate::app::Route; -use crate::components::card::Card; -use crate::components::colors::colors; -use crate::components::common::{ErrorState, LoadingState}; -use crate::components::page::{PageContainer, PageTitle}; -use crate::hooks::use_api_simple; -use crate::rl_contract::{ - self, has_trajectory_identity, is_rollout_submit_parent_span, is_rollout_worker_role, - is_step_parent_span, is_train_timeline_span, logical_step_key, LogicalStepKey, - ROLLOUT_TIMELINE_EMPTY_HINT, TRAIN_TIMELINE_EMPTY_HINT, -}; -use crate::state::rl::{ - estimate_detail_panel_height, RL_DETAIL_PANEL_HEIGHT, RL_DETAIL_PANEL_HEIGHT_DEFAULT, - RL_DETAIL_PANEL_HEIGHT_MAX, RL_DETAIL_PANEL_HEIGHT_MIN, RL_EVENT_LIMIT, ROLLOUT_FILTER, - ROLLOUT_FILTER_INPUT, -}; -use crate::utils::tracing_viewer; -use std::collections::{HashMap, HashSet}; - -/// RL observability view selected from the sidebar route. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum RlViewMode { - Rollout, - Train, - Spans, - ProcessTimeline, - Perfetto, -} - -impl RlViewMode { - pub fn title(self) -> &'static str { - match self { - Self::Rollout => "Rollout", - Self::Train => "Train", - Self::Spans => "Spans", - Self::ProcessTimeline => "Process Timeline", - Self::Perfetto => "Perfetto", - } - } - - pub fn subtitle(self) -> &'static str { - match self { - Self::Rollout => "Per-trajectory phase timing across rollout workers", - Self::Train => "Training batch phases keyed by train step", - Self::Spans => "Distributed span hierarchy with cross-process linking", - Self::ProcessTimeline => "Per-process span timing and batch drill-down", - Self::Perfetto => "Chrome trace export for the loaded span set", - } - } - - pub fn icon(self) -> &'static icondata::Icon { - match self { - Self::Rollout => &icondata::AiDeploymentUnitOutlined, - Self::Train => &icondata::AiLineChartOutlined, - Self::Spans => &icondata::AiApartmentOutlined, - Self::ProcessTimeline => &icondata::AiClockCircleOutlined, - Self::Perfetto => &icondata::AiThunderboltOutlined, - } - } - - fn internal_key(self) -> &'static str { - match self { - Self::Rollout => "rl", - Self::Train => "train", - Self::Spans => "tree", - Self::ProcessTimeline => "profiling", - Self::Perfetto => "rollout-perfetto", - } - } - - fn shows_rollout_filter(self) -> bool { - matches!(self, Self::Rollout) - } - - fn uses_rollout_filter(self) -> bool { - matches!(self, Self::Rollout | Self::Perfetto) - } -} - -#[component] -pub fn RlObservability( - view: RlViewMode, - #[props(default = true)] show_context_controls: bool, -) -> Element { - let is_perfetto = view == RlViewMode::Perfetto; - let timeline_depth = use_signal(|| 2usize); - let selected_batch_key = use_signal(String::new); - let process_filter = use_signal(|| { - if is_perfetto { - "all".to_string() - } else { - "driver".to_string() - } - }); - let function_filter = use_signal(String::new); - let discovered_processes = use_signal(Vec::::new); - let state = use_api_simple::>(); - let navigator = use_navigator(); - let view_key = view.internal_key(); - - let shows_rollout_filter = view.shows_rollout_filter(); - let uses_rollout_filter = view.uses_rollout_filter(); - - // Create dependency, recalculate when limit or rollout filter changes. - let data_query = use_memo(move || { - let rollout_id = if uses_rollout_filter { - ROLLOUT_FILTER.read().trim().to_string() - } else { - String::new() - }; - let limit = if is_perfetto { - 2000 - } else { - *RL_EVENT_LIMIT.read() - }; - (limit, rollout_id) - }); - // Refetch data when limit or rollout filter changes. - use_effect({ - let mut loading = state.loading; - let mut data = state.data; - let mut discovered_processes = discovered_processes; - move || { - let (limit_val, rollout_id) = data_query.read().clone(); - spawn(async move { - *loading.write() = true; - let client = ApiClient::new(); - let result = async { - let processes = client.get_trace_processes().await.unwrap_or_default(); - *discovered_processes.write() = processes.clone(); - - let mut spans = if rollout_id.is_empty() { - let fetch_limit = expanded_trace_fetch_limit(limit_val); - let mut spans = client.get_span_tree(Some(fetch_limit)).await?; - let local_pids = collect_span_process_pids(&spans); - for process in processes { - if local_pids.contains(&process.pid) { - continue; - } - if let Ok(mut process_spans) = client - .get_span_tree_for_pid(process.pid, Some(fetch_limit)) - .await - { - spans.append(&mut process_spans); - } - } - spans - } else { - let mut spans = client.get_span_tree_for_rollout_id(&rollout_id).await?; - let local_pids = collect_span_process_pids(&spans); - for process in processes { - if local_pids.contains(&process.pid) { - continue; - } - if let Ok(mut process_spans) = client - .get_span_tree_for_pid_and_rollout_id(process.pid, &rollout_id) - .await - { - spans.append(&mut process_spans); - } - } - spans - }; - spans = link_cross_process_spans(spans); - spans.sort_by_key(|span| span.start_timestamp); - Ok(spans) - } - .await; - *data.write() = Some(result); - *loading.write() = false; - }); - } - }); - - let display_fetch_limit = expanded_trace_fetch_limit(*RL_EVENT_LIMIT.read()); - let active_rollout_filter = if shows_rollout_filter { - ROLLOUT_FILTER.read().trim().to_string() - } else { - String::new() - }; - let rollout_filter_input_value = if shows_rollout_filter { - ROLLOUT_FILTER_INPUT.read().clone() - } else { - String::new() - }; - - if is_perfetto { - return rsx! { - div { - class: "flex h-full min-h-0 flex-col bg-white", - if state.is_loading() { - div { - class: "flex flex-1 items-center justify-center text-sm text-gray-500", - "Loading trace data..." - } - } else if let Some(Ok(spans)) = state.data.read().as_ref() { - if spans.is_empty() { - div { - class: "flex flex-1 items-center justify-center p-8 text-center text-sm text-gray-500", - "No trace data available. Start tracing with probing.tracing.span()" - } - } else { - { - let all_display_spans = build_display_spans(spans); - let display_spans = filter_display_spans( - &all_display_spans, - "all", - "", - ); - rsx! { - RolloutPerfettoTimeline { - spans: display_spans, - fullscreen: true, - } - } - } - } - } else if let Some(Err(err)) = state.data.read().as_ref() { - div { - class: "flex flex-1 items-center justify-center p-8", - ErrorState { error: err.display_message(), title: None } - } - } - } - }; - } - - rsx! { - PageContainer { - PageTitle { - title: view.title().to_string(), - subtitle: Some(view.subtitle().to_string()), - icon: Some(view.icon()), - } - if show_context_controls { - Card { - title: "Context", - div { - class: "space-y-2", - if shows_rollout_filter { - div { - class: "flex flex-wrap items-center gap-2 rounded border border-blue-100 bg-blue-50 px-3 py-2", - span { - class: "text-sm text-gray-700", - "Rollout ID" - } - input { - r#type: "text", - value: "{rollout_filter_input_value}", - placeholder: "e.g. 12", - class: "w-40 rounded border border-gray-200 bg-white px-2 py-1 text-sm font-mono", - oninput: move |ev| *ROLLOUT_FILTER_INPUT.write() = ev.value() - } - button { - class: format!("rounded px-3 py-1.5 text-sm bg-{} text-white", colors::PRIMARY), - onclick: { - let mut selected_batch_key = selected_batch_key; - let mut process_filter = process_filter; - let nav = navigator; - move |_| { - *ROLLOUT_FILTER.write() = - ROLLOUT_FILTER_INPUT.read().trim().to_string(); - *selected_batch_key.write() = String::new(); - *process_filter.write() = "all".to_string(); - nav.push(Route::RolloutPage {}); - } - }, - "Load rollout" - } - button { - class: "rounded bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100", - onclick: { - let mut selected_batch_key = selected_batch_key; - move |_| { - *ROLLOUT_FILTER_INPUT.write() = String::new(); - *ROLLOUT_FILTER.write() = String::new(); - *selected_batch_key.write() = String::new(); - } - }, - "Clear" - } - span { - class: "text-xs text-gray-600", - if active_rollout_filter.is_empty() { - "Empty means latest-event mode." - } else { - "Fetching all spans tagged with rollout_id={active_rollout_filter} across processes." - } - } - } - } - div { - class: "flex items-center justify-between", - span { - class: "text-sm text-gray-600", - "Number of Events" - } - span { - class: "text-sm text-gray-800 font-mono", - "{*RL_EVENT_LIMIT.read()} events" - } - } - input { - r#type: "range", - min: "100", - max: "2000", - step: "100", - value: "{*RL_EVENT_LIMIT.read()}", - class: "w-full", - oninput: move |ev| { - if let Ok(val) = ev.value().parse::() { - *RL_EVENT_LIMIT.write() = val; - } - } - } - div { - class: "flex justify-between text-xs text-gray-500", - span { "100" } - span { "2000" } - } - div { - class: "text-xs text-gray-500", - if shows_rollout_filter && active_rollout_filter.is_empty() { - "Trace linking fetches up to {display_fetch_limit} events per process to keep cross-process parents in range." - } else if shows_rollout_filter { - "Rollout ID mode ignores the event limit and fetches all matching spans for the selected rollout." - } else { - "Fetches up to {display_fetch_limit} events per process." - } - } - } - } - } - - Card { - title: "Trace Data", - if state.is_loading() { - LoadingState { message: Some("Loading trace data...".to_string()) } - } else if let Some(Ok(spans)) = state.data.read().as_ref() { - if spans.is_empty() { - div { - class: "text-center py-8 text-gray-500", - "No trace data available. Start tracing with probing.tracing.span()" - } - } else { - { - let all_display_spans = build_display_spans(spans); - let process_options = collect_process_options(&all_display_spans, &discovered_processes.read()); - let function_options = collect_function_options(&all_display_spans); - let selected_process_filter = process_filter.read().clone(); - let selected_function_filter = function_filter.read().clone(); - let display_spans = filter_display_spans( - &all_display_spans, - &selected_process_filter, - &selected_function_filter, - ); - let discovered_count = discovered_processes.read().len(); - let batch_options = collect_batch_options(&display_spans); - let current_batch_key = selected_batch_key.read().clone(); - let selected_batch_key_for_view = if batch_options.iter().any(|batch| batch.unique_key == current_batch_key) { - current_batch_key - } else { - String::new() - }; - let selected_batch_label = batch_options - .iter() - .find(|batch| batch.unique_key == selected_batch_key_for_view) - .map(|batch| batch.display_id.clone()) - .unwrap_or_else(|| "All batches".to_string()); - let depth_value = *timeline_depth.read(); - let link_summary = cross_process_link_summary_view(&display_spans); - - rsx! { - div { - class: "space-y-6", - div { - class: "rounded border border-gray-100 bg-gray-50 px-3 py-3", - div { - class: "flex flex-wrap items-center gap-4", - div { - class: "flex items-center gap-2", - span { class: "text-sm text-gray-600", "Process" } - select { - class: "max-w-[360px] rounded border border-gray-200 bg-white px-2 py-1 text-sm", - value: "{selected_process_filter}", - oninput: { - let mut process_filter = process_filter; - let mut selected_batch_key = selected_batch_key; - move |ev| { - *process_filter.write() = ev.value(); - *selected_batch_key.write() = String::new(); - } - }, - option { value: "driver", "Driver (default)" } - option { value: "all", "All processes with spans" } - for process_option in process_options.iter() { - option { - value: "{process_option.filter_key}", - "{process_option.label}" - } - } - } - } - div { - class: "flex items-center gap-2", - span { class: "text-sm text-gray-600", "Function" } - select { - class: "max-w-[360px] rounded border border-gray-200 bg-white px-2 py-1 text-sm", - value: "{selected_function_filter}", - oninput: { - let mut function_filter = function_filter; - let mut selected_batch_key = selected_batch_key; - move |ev| { - *function_filter.write() = ev.value(); - *selected_batch_key.write() = String::new(); - } - }, - option { value: "", "All probing spans" } - for function_option in function_options.iter() { - option { - value: "{function_option.filter_key}", - "{function_option.label}" - } - } - } - } - span { - class: "text-xs text-gray-500", - "discovered {discovered_count} Ray processes, showing {display_spans.len()} roots" - } - } - } - div { - class: "rounded border border-gray-100 bg-gray-50 px-3 py-2 text-xs font-mono text-gray-600", - "Cross Process Link: linked {link_summary.linked}, orphan {link_summary.orphan}, rollout parents {link_summary.parents}" - if !link_summary.orphan_keys.is_empty() { - span { - class: "ml-2 text-gray-500", - "orphan keys: {link_summary.orphan_keys}" - } - } - } - if view_key == "profiling" { - div { - class: "flex flex-wrap items-center gap-4 rounded border border-gray-100 bg-gray-50 px-3 py-2", - div { - class: "flex items-center gap-2", - span { class: "text-sm text-gray-600", "Depth" } - select { - class: "rounded border border-gray-200 bg-white px-2 py-1 text-sm", - value: "{depth_value}", - oninput: { - let mut timeline_depth = timeline_depth; - move |ev| { - if let Ok(val) = ev.value().parse::() { - *timeline_depth.write() = val; - } - } - }, - option { value: "1", "Level 1" } - option { value: "2", "Level 2" } - option { value: "3", "Level 3" } - option { value: "99", "All" } - } - } - div { - class: "flex items-center gap-2", - span { class: "text-sm text-gray-600", "Batch" } - select { - class: "max-w-[320px] rounded border border-gray-200 bg-white px-2 py-1 text-sm", - value: "{selected_batch_key_for_view}", - oninput: { - let mut selected_batch_key = selected_batch_key; - move |ev| *selected_batch_key.write() = ev.value() - }, - option { value: "", "All batches" } - for batch in batch_options.iter() { - option { - value: "{batch.unique_key}", - "{batch.display_id} | {batch.process_label} | span_id:{batch.span_id}" - } - } - } - } - span { - class: "text-xs text-gray-500", - "selected: {selected_batch_label}" - } - } - SpanTimeline { - spans: display_spans.clone(), - max_depth: depth_value, - selected_batch_key: selected_batch_key_for_view.clone(), - } - } else if view_key == "rl" { - RlTimeline { - spans: display_spans.clone(), - } - } else if view_key == "rollout-perfetto" { - RolloutPerfettoTimeline { - spans: display_spans.clone(), - } - } else if view_key == "train" { - TrainTimeline { - spans: display_spans.clone(), - } - } else { - div { - class: "space-y-4", - for span in display_spans.iter() { - SpanView { span: span.clone(), depth: 0 } - } - } - } - } - } - } - } - } else if let Some(Err(err)) = state.data.read().as_ref() { - ErrorState { error: err.display_message(), title: None } - } - } - } - } -} - -#[allow(dead_code)] -#[component] -fn _unused_legacy_traces() -> Element { - rsx! { - RlObservability { view: RlViewMode::Rollout } - } -} - -#[derive(Clone, PartialEq)] -struct DisplaySpan { - unique_key: String, - span_id: i64, - trace_id: i64, - parent_id: Option, - name: String, - display_id: String, - start_timestamp: i64, - end_timestamp: Option, - thread_id: i64, - kind: Option, - location: Option, - attributes: Option, - process_label: String, - children: Vec, - events: Vec, -} - -#[derive(Clone, PartialEq)] -struct FilterOption { - filter_key: String, - label: String, -} - -#[component] -fn SpanView(span: DisplaySpan, depth: usize) -> Element { - let indent = depth * 24; - let duration = span - .end_timestamp - .map(|end| format_duration_ns(end - span.start_timestamp)) - .unwrap_or_else(|| "running".to_string()); - - let mut expanded = use_signal(|| depth < 2); // Auto-expand first 2 levels - - rsx! { - div { - class: "border-l-2 border-gray-200 pl-4", - style: format!("margin-left: {}px", indent), - div { - class: "flex items-center gap-2 py-2 hover:bg-gray-50 rounded px-2 flex-wrap", - button { - class: "text-gray-400 hover:text-gray-600 flex-shrink-0", - onclick: move |_| { - let current = *expanded.read(); - *expanded.write() = !current; - }, - if *expanded.read() { - "▼" - } else { - "▶" - } - } - span { - class: "font-semibold text-gray-900", - "{span.name}" - } - span { - class: "text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded font-mono", - "{span.display_id}" - } - span { - class: "text-xs px-2 py-0.5 bg-gray-100 text-gray-600 rounded font-mono", - "{span.process_label}" - } - if let Some(ref kind) = span.kind { - span { - class: format!("text-xs px-2 py-0.5 bg-{} text-{} rounded", colors::CONTENT_ACCENT_BG, colors::CONTENT_ACCENT_TEXT), - "{kind}" - } - } - // Display location in header - if let Some(ref location) = span.location { - if !location.is_empty() { - span { - class: "text-xs px-2 py-0.5 bg-gray-100 text-gray-700 rounded font-mono", - "{location}" - } - } - } - span { - class: "text-sm text-gray-500", - "span_id: {span.span_id}" - } - if let Some(ref parent_id) = span.parent_id { - span { - class: "text-sm text-gray-400", - "parent: {parent_id}" - } - } - span { - class: "text-sm text-gray-500", - "thread: {span.thread_id}" - } - span { - class: "text-sm font-mono text-green-600", - "{duration}" - } - } - - if *expanded.read() { - div { - class: "ml-6 space-y-2", - // Attributes - displayed first - if let Some(ref attrs) = span.attributes { - if !attrs.is_empty() { - div { - class: "text-xs text-gray-500 mb-1", - "Attributes:" - } - // Try to parse and format JSON attributes - if let Ok(parsed) = serde_json::from_str::(attrs) { - if let Some(obj) = parsed.as_object() { - div { - class: "bg-gray-50 p-2 rounded mt-1 space-y-1", - for (key, val) in obj.iter() { - div { - class: "flex items-start gap-2 text-xs", - span { - class: "font-semibold text-gray-700 min-w-[100px]", - "{key}:" - } - span { - class: "text-gray-600 font-mono break-all", - { - match val { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Null => "null".to_string(), - _ => format!("{}", val), - } - } - } - } - } - } - } else { - div { - class: "text-xs font-mono bg-gray-50 p-2 rounded mt-1 break-all", - "{attrs}" - } - } - } else { - div { - class: "text-xs font-mono bg-gray-50 p-2 rounded mt-1 break-all", - "{attrs}" - } - } - } - } - - // Events - if !span.events.is_empty() { - div { - class: "text-xs text-gray-500 mb-1 mt-2", - "Events ({span.events.len()}):" - } - for event in span.events.iter() { - EventView { event: event.clone() } - } - } - - // Children spans - if !span.children.is_empty() { - div { - class: "text-xs text-gray-500 mb-1 mt-2", - "Child Spans ({span.children.len()}):" - } - for child in span.children.iter() { - SpanView { span: child.clone(), depth: depth + 1 } - } - } - } - } - } - } -} - -#[derive(Clone, PartialEq)] -struct TimelineSpan { - unique_key: String, - name: String, - display_id: String, - span_id: i64, - kind: Option, - location: Option, - start: i64, - duration: i64, - depth: usize, - process_label: String, -} - -#[derive(Clone, Default)] -struct CrossProcessLinkSummaryView { - linked: usize, - orphan: usize, - parents: usize, - orphan_keys: String, -} - -#[component] -fn SpanTimeline(spans: Vec, max_depth: usize, selected_batch_key: String) -> Element { - let rows = build_timeline_spans(&spans, max_depth, &selected_batch_key); - if rows.is_empty() { - return rsx! { - div { - class: "text-sm text-gray-500", - "No completed spans available for timeline." - } - }; - } - - let min_start = rows.iter().map(|row| row.start).min().unwrap_or(0); - let max_end = rows - .iter() - .map(|row| row.start + row.duration) - .max() - .unwrap_or(min_start + 1); - let window = (max_end - min_start).max(1); - let window_f = window as f64; - let window_label = format_duration_ns(window); - - rsx! { - div { - class: "border-b border-gray-100 pb-4", - div { - class: "mb-3 flex items-center justify-between gap-4", - h4 { - class: "text-sm font-semibold text-gray-900", - "Timeline" - } - span { - class: "text-xs font-mono text-gray-500", - "window {window_label}" - } - } - div { - class: "space-y-1", - for row in rows.iter() { - { - let left = ((row.start - min_start) as f64 / window_f * 100.0).clamp(0.0, 100.0); - let width = (row.duration as f64 / window_f * 100.0) - .max(0.5) - .min((100.0 - left).max(0.5)); - let start_offset = row.start - min_start; - let end_offset = start_offset + row.duration; - let start_label = format_duration_ns(start_offset); - let end_label = format_duration_ns(end_offset); - let duration_label = format_duration_ns(row.duration); - let tooltip = format!( - "{} ({})\nprocess: {}\nspan_id: {}\nstart: {}\nend: {}\nduration: {}", - row.name, row.display_id, row.process_label, row.span_id, start_label, end_label, duration_label, - ); - rsx! { - div { - class: "grid grid-cols-[minmax(220px,34%)_1fr_auto] items-center gap-3 text-sm", - title: "{tooltip}", - div { - class: "min-w-0", - style: "padding-left: {row.depth.min(8) * 14}px", - div { - class: "flex items-center gap-2 min-w-0", - span { - class: "font-medium text-gray-900 truncate", - "{row.name}" - } - span { - class: "text-xs px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded font-mono flex-shrink-0", - "{row.display_id}" - } - span { - class: "text-xs px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded font-mono flex-shrink-0", - "{row.process_label}" - } - if let Some(ref kind) = row.kind { - span { - class: format!("text-xs px-1.5 py-0.5 bg-{} text-{} rounded flex-shrink-0", colors::CONTENT_ACCENT_BG, colors::CONTENT_ACCENT_TEXT), - "{kind}" - } - } - } - if let Some(ref location) = row.location { - if !location.is_empty() { - div { - class: "mt-0.5 text-xs font-mono text-gray-400 truncate", - "{location}" - } - } - } - } - div { - class: "relative h-5 rounded bg-gray-100 overflow-hidden", - div { - class: "absolute top-1 bottom-1 rounded bg-blue-500", - style: "left: {left:.2}%; width: {width:.2}%;", - } - } - div { - class: "font-mono text-xs text-gray-600 text-right w-44", - "{start_label} -> {end_label} | {duration_label}" - } - } - } - } - } - } - } - } -} - -#[derive(Clone, PartialEq)] -struct RlPhaseSegment { - name: String, - phase: String, - start: i64, - end: i64, - process_label: String, - span_id: i64, - attributes: Option, - turn_id: Option, - env_step_id: Option, -} - -#[derive(Clone, PartialEq)] -struct RlSampleRow { - sample_key: String, - label: String, - detail: String, - start: i64, - end: i64, - process_labels: String, - segments: Vec, -} - -#[derive(Clone, PartialEq)] -struct RlRolloutView { - rollout_id: String, - start: i64, - end: i64, - samples: Vec, -} - -#[derive(Default)] -struct RlSampleBuilder { - sample_key: String, - label: String, - rollout_id: String, - group_id: Option, - attempt: Option, - start: i64, - end: i64, - process_labels: HashSet, - segments: Vec, -} - -#[component] -fn RolloutPerfettoTimeline(spans: Vec, #[props(default)] fullscreen: bool) -> Element { - let trace_json = build_chrome_trace_json_from_display_spans(&spans); - let container_class = if fullscreen { - "relative flex-1 min-h-0 overflow-hidden bg-white" - } else { - "relative min-h-[680px] overflow-hidden rounded border border-gray-200 bg-white" - }; - let iframe_style = if fullscreen { - "width: 100%; height: 100%; border: none; display: block;" - } else { - "width: 100%; height: 680px; border: none; display: block;" - }; - - rsx! { - div { - class: if fullscreen { "flex h-full min-h-0 flex-col" } else { "space-y-3" }, - if !fullscreen { - div { - class: "rounded border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-600", - "Perfetto view for the currently loaded trace data. Use the Rollout ID filter above to focus this panel on one rollout." - } - } - div { - class: "{container_class}", - if let Some(trace_json) = trace_json { - iframe { - srcdoc: tracing_viewer::get_tracing_viewer_html(&trace_json), - style: "{iframe_style}", - title: "Perfetto" - } - } else { - div { - class: "absolute inset-0 flex items-center justify-center p-8", - div { - class: "text-center text-gray-500", - p { class: "mb-2 text-lg", "No timeline spans with end timestamps" } - p { class: "text-sm", "Load a rollout from the Rollout page, then return here." } - } - } - } - } - } - } -} - -#[component] -fn RlTimeline(spans: Vec) -> Element { - let rollouts = build_rl_rollout_views(&spans); - if rollouts.is_empty() { - return rsx! { - div { - class: "text-sm text-gray-500", - "{ROLLOUT_TIMELINE_EMPTY_HINT}" - } - }; - } - - let selected_rollout = use_signal(|| { - rollouts - .first() - .map(|rollout| rollout.rollout_id.clone()) - .unwrap_or_default() - }); - let selected_sample = use_signal(String::new); - let sort_mode = use_signal(|| "time".to_string()); - let selected_rollout_read = selected_rollout.read().clone(); - let selected_rollout_id = if rollouts - .iter() - .any(|rollout| rollout.rollout_id == selected_rollout_read) - { - selected_rollout.read().clone() - } else { - rollouts - .first() - .map(|rollout| rollout.rollout_id.clone()) - .unwrap_or_default() - }; - let rollout = rollouts - .iter() - .find(|rollout| rollout.rollout_id == selected_rollout_id) - .or_else(|| rollouts.first()) - .unwrap(); - let selected_sample_read = selected_sample.read().clone(); - let selected_sample_key = if rollout - .samples - .iter() - .any(|sample| sample.sample_key == selected_sample_read) - { - selected_sample.read().clone() - } else { - rollout - .samples - .first() - .map(|sample| sample.sample_key.clone()) - .unwrap_or_default() - }; - let selected_sample_row = rollout - .samples - .iter() - .find(|sample| sample.sample_key == selected_sample_key); - let min_start = rollout.start; - let max_end = rollout.end.max(min_start + 1); - let window = (max_end - min_start).max(1); - let window_f = window as f64; - let window_label = format_duration_ns(window); - let sample_count = rollout.samples.len(); - let segment_count = rollout - .samples - .iter() - .map(|sample| sample.segments.len()) - .sum::(); - let sort_mode_value = sort_mode.read().clone(); - let mut display_samples = rollout.samples.clone(); - if sort_mode_value == "slow" { - display_samples.sort_by(|a, b| { - sample_duration_ns(b) - .cmp(&sample_duration_ns(a)) - .then_with(|| a.start.cmp(&b.start)) - }); - } else { - display_samples.sort_by_key(|sample| sample.start); - } - let slowest_sample = rollout - .samples - .iter() - .max_by_key(|sample| sample_duration_ns(sample)); - let slowest_label = slowest_sample - .map(|sample| { - format!( - "slowest {} {}", - sample.label, - format_duration_ns(sample_duration_ns(sample)) - ) - }) - .unwrap_or_default(); - let detail_bottom_pad = if selected_sample_row.is_some() { - *RL_DETAIL_PANEL_HEIGHT.read() + 12 - } else { - 0 - }; - - rsx! { - div { - class: "border-b border-gray-100", - style: if detail_bottom_pad > 0 { format!("padding-bottom: {detail_bottom_pad}px") } else { String::new() }, - div { - class: "mb-3 flex items-start justify-between gap-4", - div { - h4 { - class: "text-sm font-semibold text-gray-900", - "Rollout" - } - div { - class: "text-xs text-gray-500", - "Grouped by rollout_id and trajectory_id. Each row is one rollout trajectory." - } - } - span { - class: "text-xs font-mono text-gray-500", - "window {window_label}" - } - } - RlPhaseLegend {} - div { - class: "mb-3 flex flex-wrap items-center gap-3", - span { class: "text-sm text-gray-600", "Rollout" } - for rollout_option in rollouts.iter() { - { - let is_selected = rollout_option.rollout_id == selected_rollout_id; - let rollout_id = rollout_option.rollout_id.clone(); - rsx! { - button { - class: format!("rounded px-2 py-1 text-xs font-mono {}", if is_selected { format!("bg-{} text-white", colors::PRIMARY) } else { "bg-gray-100 text-gray-700 hover:bg-gray-200".to_string() }), - onclick: { - let mut selected_rollout = selected_rollout; - let mut selected_sample = selected_sample; - move |_| { - *selected_rollout.write() = rollout_id.clone(); - *selected_sample.write() = String::new(); - } - }, - "{rollout_option.rollout_id} ({rollout_option.samples.len()})" - } - } - } - } - span { - class: "ml-2 text-xs text-gray-500", - "{sample_count} trajectories, {segment_count} phase spans" - } - if !slowest_label.is_empty() { - span { - class: "rounded bg-orange-50 px-2 py-1 text-xs font-mono text-orange-700", - "{slowest_label}" - } - } - } - div { - class: "mb-3 flex flex-wrap items-center gap-2 rounded border border-gray-100 bg-gray-50 px-3 py-2", - span { class: "text-sm text-gray-600", "Sort" } - button { - class: format!("rounded px-2 py-1 text-xs {}", if sort_mode_value == "time" { format!("bg-{} text-white", colors::PRIMARY) } else { "bg-white text-gray-700 hover:bg-gray-100".to_string() }), - onclick: { - let mut sort_mode = sort_mode; - move |_| *sort_mode.write() = "time".to_string() - }, - "timeline order" - } - button { - class: format!("rounded px-2 py-1 text-xs {}", if sort_mode_value == "slow" { format!("bg-{} text-white", colors::PRIMARY) } else { "bg-white text-gray-700 hover:bg-gray-100".to_string() }), - onclick: { - let mut sort_mode = sort_mode; - move |_| *sort_mode.write() = "slow".to_string() - }, - "slowest first" - } - span { - class: "text-xs text-gray-500", - "Tip: click a trajectory row to pin its phase details below." - } - } - div { - class: "space-y-1", - for sample in display_samples.iter() { - { - let start_offset = sample.start - min_start; - let end_offset = sample.end - min_start; - let duration = (sample.end - sample.start).max(0); - let start_label = format_duration_ns(start_offset); - let end_label = format_duration_ns(end_offset); - let duration_label = format_duration_ns(duration); - let is_selected = sample.sample_key == selected_sample_key; - rsx! { - button { - class: format!("grid w-full grid-cols-[minmax(300px,34%)_1fr_auto] items-center gap-3 rounded px-2 py-1 text-left text-sm {}", if is_selected { "bg-blue-50 ring-1 ring-blue-200".to_string() } else { "hover:bg-gray-50".to_string() }), - onclick: { - let mut selected_sample = selected_sample; - let sample_key = sample.sample_key.clone(); - move |_| *selected_sample.write() = sample_key.clone() - }, - div { - class: "min-w-0", - div { - class: "flex items-center gap-2 min-w-0", - span { - class: "font-medium text-gray-900 truncate", - "{sample.label}" - } - if !sample.detail.is_empty() { - span { - class: "text-xs px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded font-mono flex-shrink-0", - "{sample.detail}" - } - } - span { - class: "text-xs px-1.5 py-0.5 bg-orange-50 text-orange-700 rounded font-mono flex-shrink-0", - "{duration_label}" - } - } - div { - class: "mt-0.5 truncate text-xs text-gray-400", - "{sample.process_labels}" - } - } - div { - class: "relative h-6 rounded bg-gray-100 overflow-hidden", - for segment in sample.segments.iter() { - { - let left = ((segment.start - min_start) as f64 / window_f * 100.0).clamp(0.0, 100.0); - let width = ((segment.end - segment.start).max(0) as f64 / window_f * 100.0) - .max(0.35) - .min((100.0 - left).max(0.35)); - let phase_class = rl_phase_bar_class(&segment.phase); - let title = format!( - "{} phase={} process={} duration={}", - segment.name, - segment.phase, - segment.process_label, - format_duration_ns((segment.end - segment.start).max(0)), - ); - rsx! { - div { - class: "{phase_class}", - title: "{title}", - style: "left: {left:.2}%; width: {width:.2}%;", - } - } - } - } - } - div { - class: "font-mono text-xs text-gray-600 text-right w-44", - "{start_label} -> {end_label} | {duration_label}" - } - } - } - } - } - } - if let Some(sample) = selected_sample_row { - RlSampleDetail { sample: sample.clone(), min_start } - } - } - } -} - -#[component] -fn TrainTimeline(spans: Vec) -> Element { - let rollouts = build_train_rollout_views(&spans); - if rollouts.is_empty() { - return rsx! { - div { - class: "text-sm text-gray-500", - "{TRAIN_TIMELINE_EMPTY_HINT}" - } - }; - } - - let selected_rollout = use_signal(|| { - rollouts - .first() - .map(|rollout| rollout.rollout_id.clone()) - .unwrap_or_default() - }); - let selected_batch = use_signal(String::new); - let sort_mode = use_signal(|| "time".to_string()); - let selected_rollout_read = selected_rollout.read().clone(); - let selected_rollout_id = if rollouts - .iter() - .any(|rollout| rollout.rollout_id == selected_rollout_read) - { - selected_rollout.read().clone() - } else { - rollouts - .first() - .map(|rollout| rollout.rollout_id.clone()) - .unwrap_or_default() - }; - let rollout = rollouts - .iter() - .find(|rollout| rollout.rollout_id == selected_rollout_id) - .or_else(|| rollouts.first()) - .unwrap(); - let selected_batch_read = selected_batch.read().clone(); - let selected_batch_key = if rollout - .samples - .iter() - .any(|batch| batch.sample_key == selected_batch_read) - { - selected_batch.read().clone() - } else { - rollout - .samples - .first() - .map(|batch| batch.sample_key.clone()) - .unwrap_or_default() - }; - let selected_batch_row = rollout - .samples - .iter() - .find(|batch| batch.sample_key == selected_batch_key); - let min_start = rollout.start; - let max_end = rollout.end.max(min_start + 1); - let window = (max_end - min_start).max(1); - let window_f = window as f64; - let window_label = format_duration_ns(window); - let batch_count = rollout.samples.len(); - let segment_count = rollout - .samples - .iter() - .map(|batch| batch.segments.len()) - .sum::(); - let sort_mode_value = sort_mode.read().clone(); - let mut display_batches = rollout.samples.clone(); - if sort_mode_value == "slow" { - display_batches.sort_by(|a, b| { - sample_duration_ns(b) - .cmp(&sample_duration_ns(a)) - .then_with(|| a.start.cmp(&b.start)) - }); - } else { - display_batches.sort_by_key(|batch| batch.start); - } - let slowest_batch = rollout - .samples - .iter() - .max_by_key(|batch| sample_duration_ns(batch)); - let slowest_label = slowest_batch - .map(|batch| { - format!( - "slowest {} {}", - batch.label, - format_duration_ns(sample_duration_ns(batch)) - ) - }) - .unwrap_or_default(); - let detail_bottom_pad = if selected_batch_row.is_some() { - *RL_DETAIL_PANEL_HEIGHT.read() + 12 - } else { - 0 - }; - - rsx! { - div { - class: "border-b border-gray-100", - style: if detail_bottom_pad > 0 { format!("padding-bottom: {detail_bottom_pad}px") } else { String::new() }, - div { - class: "mb-3 flex items-start justify-between gap-4", - div { - h4 { - class: "text-sm font-semibold text-gray-900", - "Train" - } - div { - class: "text-xs text-gray-500", - "Grouped by rollout_id and train_step_id/batch_id. Each row is one train batch." - } - } - span { - class: "text-xs font-mono text-gray-500", - "window {window_label}" - } - } - RlPhaseLegend {} - div { - class: "mb-3 flex flex-wrap items-center gap-3", - span { class: "text-sm text-gray-600", "Rollout" } - for rollout_option in rollouts.iter() { - { - let is_selected = rollout_option.rollout_id == selected_rollout_id; - let rollout_id = rollout_option.rollout_id.clone(); - rsx! { - button { - class: format!("rounded px-2 py-1 text-xs font-mono {}", if is_selected { format!("bg-{} text-white", colors::PRIMARY) } else { "bg-gray-100 text-gray-700 hover:bg-gray-200".to_string() }), - onclick: { - let mut selected_rollout = selected_rollout; - let mut selected_batch = selected_batch; - move |_| { - *selected_rollout.write() = rollout_id.clone(); - *selected_batch.write() = String::new(); - } - }, - "{rollout_option.rollout_id} ({rollout_option.samples.len()})" - } - } - } - } - span { - class: "ml-2 text-xs text-gray-500", - "{batch_count} batches, {segment_count} phase spans" - } - if !slowest_label.is_empty() { - span { - class: "rounded bg-orange-50 px-2 py-1 text-xs font-mono text-orange-700", - "{slowest_label}" - } - } - } - div { - class: "mb-3 flex flex-wrap items-center gap-2 rounded border border-gray-100 bg-gray-50 px-3 py-2", - span { class: "text-sm text-gray-600", "Sort" } - button { - class: format!("rounded px-2 py-1 text-xs {}", if sort_mode_value == "time" { format!("bg-{} text-white", colors::PRIMARY) } else { "bg-white text-gray-700 hover:bg-gray-100".to_string() }), - onclick: { - let mut sort_mode = sort_mode; - move |_| *sort_mode.write() = "time".to_string() - }, - "timeline order" - } - button { - class: format!("rounded px-2 py-1 text-xs {}", if sort_mode_value == "slow" { format!("bg-{} text-white", colors::PRIMARY) } else { "bg-white text-gray-700 hover:bg-gray-100".to_string() }), - onclick: { - let mut sort_mode = sort_mode; - move |_| *sort_mode.write() = "slow".to_string() - }, - "slowest first" - } - span { - class: "text-xs text-gray-500", - "Tip: click a train batch row to pin its phase details below." - } - } - div { - class: "space-y-1", - for batch in display_batches.iter() { - { - let start_offset = batch.start - min_start; - let end_offset = batch.end - min_start; - let duration = (batch.end - batch.start).max(0); - let start_label = format_duration_ns(start_offset); - let end_label = format_duration_ns(end_offset); - let duration_label = format_duration_ns(duration); - let is_selected = batch.sample_key == selected_batch_key; - rsx! { - button { - class: format!("grid w-full grid-cols-[minmax(300px,34%)_1fr_auto] items-center gap-3 rounded px-2 py-1 text-left text-sm {}", if is_selected { "bg-blue-50 ring-1 ring-blue-200".to_string() } else { "hover:bg-gray-50".to_string() }), - onclick: { - let mut selected_batch = selected_batch; - let batch_key = batch.sample_key.clone(); - move |_| *selected_batch.write() = batch_key.clone() - }, - div { - class: "min-w-0", - div { - class: "flex items-center gap-2 min-w-0", - span { - class: "font-medium text-gray-900 truncate", - "{batch.label}" - } - if !batch.detail.is_empty() { - span { - class: "text-xs px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded font-mono flex-shrink-0", - "{batch.detail}" - } - } - span { - class: "text-xs px-1.5 py-0.5 bg-orange-50 text-orange-700 rounded font-mono flex-shrink-0", - "{duration_label}" - } - } - div { - class: "mt-0.5 truncate text-xs text-gray-400", - "{batch.process_labels}" - } - } - div { - class: "relative h-6 rounded bg-gray-100 overflow-hidden", - for segment in batch.segments.iter() { - { - let left = ((segment.start - min_start) as f64 / window_f * 100.0).clamp(0.0, 100.0); - let width = ((segment.end - segment.start).max(0) as f64 / window_f * 100.0) - .max(0.35) - .min((100.0 - left).max(0.35)); - let phase_class = rl_phase_bar_class(&segment.phase); - let title = format!( - "{} phase={} process={} duration={}", - segment.name, - segment.phase, - segment.process_label, - format_duration_ns((segment.end - segment.start).max(0)), - ); - rsx! { - div { - class: "{phase_class}", - title: "{title}", - style: "left: {left:.2}%; width: {width:.2}%;", - } - } - } - } - } - div { - class: "font-mono text-xs text-gray-600 text-right w-44", - "{start_label} -> {end_label} | {duration_label}" - } - } - } - } - } - } - if let Some(batch) = selected_batch_row { - TrainBatchDetail { batch: batch.clone(), min_start } - } - } - } -} - -#[component] -fn ResizableDetailPanel( - title: String, - subtitle: String, - segment_count: usize, - children: Element, -) -> Element { - let panel_height = use_signal(|| *RL_DETAIL_PANEL_HEIGHT.read()); - let is_resizing = use_signal(|| false); - let drag_start_y = use_signal(|| 0.0f64); - let drag_start_height = use_signal(|| 0.0f64); - let height_px = *panel_height.read(); - let height_label = format!("{height_px}px"); - let resizing = *is_resizing.read(); - - rsx! { - if resizing { - div { - class: "fixed inset-0 z-[60] cursor-ns-resize select-none", - style: "touch-action: none;", - onmousemove: { - let mut panel_height = panel_height; - move |ev| { - let current_y = ev.client_coordinates().y; - let delta = *drag_start_y.read() - current_y; - let new_height = (*drag_start_height.read() + delta) - .max(RL_DETAIL_PANEL_HEIGHT_MIN as f64) - .min(RL_DETAIL_PANEL_HEIGHT_MAX as f64) as u32; - *panel_height.write() = new_height; - *RL_DETAIL_PANEL_HEIGHT.write() = new_height; - } - }, - onmouseup: { - let mut is_resizing = is_resizing; - move |_| { - *is_resizing.write() = false; - } - }, - } - } - div { - class: "fixed bottom-0 left-0 right-0 z-50 flex flex-col border-t border-gray-200 bg-white/95 shadow-2xl backdrop-blur", - style: "height: {height_px}px;", - div { - class: "group flex h-3 shrink-0 cursor-ns-resize items-center justify-center border-b border-gray-100 bg-gray-50 hover:bg-gray-100 active:bg-gray-200", - title: "Drag up/down to resize panel height", - onmousedown: { - let mut is_resizing = is_resizing; - let mut drag_start_y = drag_start_y; - let mut drag_start_height = drag_start_height; - move |ev| { - *is_resizing.write() = true; - *drag_start_y.write() = ev.client_coordinates().y; - *drag_start_height.write() = *panel_height.read() as f64; - ev.prevent_default(); - } - }, - div { class: "h-1 w-16 rounded-full bg-gray-300 group-hover:bg-gray-400 group-active:bg-gray-500" } - } - div { - class: "mx-auto flex min-h-0 w-full max-w-[1400px] flex-1 flex-col px-3 pb-3", - div { - class: "mb-2 flex shrink-0 items-center justify-between gap-3 pt-2", - div { - class: "min-w-0", - h5 { class: "text-sm font-semibold text-gray-900", "{title}" } - div { class: "text-xs text-gray-500 truncate", "{subtitle}" } - } - div { - class: "flex shrink-0 items-center gap-2", - span { - class: "hidden sm:inline text-xs font-mono text-gray-500", - "{segment_count} phases · {height_label}" - } - button { - class: "rounded border border-gray-200 bg-white px-2 py-0.5 text-xs text-gray-700 hover:bg-gray-50", - title: "Expand to fit all phases", - onclick: { - let mut panel_height = panel_height; - move |_| { - let h = estimate_detail_panel_height(segment_count); - *panel_height.write() = h; - *RL_DETAIL_PANEL_HEIGHT.write() = h; - } - }, - "Expand all" - } - button { - class: "rounded border border-gray-200 bg-white px-2 py-0.5 text-xs text-gray-700 hover:bg-gray-50", - title: "Reset panel height", - onclick: { - let mut panel_height = panel_height; - move |_| { - *panel_height.write() = RL_DETAIL_PANEL_HEIGHT_DEFAULT; - *RL_DETAIL_PANEL_HEIGHT.write() = RL_DETAIL_PANEL_HEIGHT_DEFAULT; - } - }, - "Reset" - } - } - } - div { - class: "min-h-0 flex-1 space-y-1 overflow-y-auto pr-1", - {children} - } - } - } - } -} - -#[component] -fn TrainBatchDetail(batch: RlSampleRow, min_start: i64) -> Element { - let batch_duration = format_duration_ns(sample_duration_ns(&batch)); - rsx! { - ResizableDetailPanel { - title: format!("Train Batch Detail: {}", batch.label), - subtitle: format!("{} | duration {}", batch.detail, batch_duration), - segment_count: batch.segments.len(), - for segment in batch.segments.iter() { - { - let start = format_duration_ns(segment.start - min_start); - let end = format_duration_ns(segment.end - min_start); - let duration = format_duration_ns((segment.end - segment.start).max(0)); - let dot_class = rl_phase_dot_class(&segment.phase); - rsx! { - div { - class: "grid grid-cols-[minmax(180px,22%)_130px_1fr] items-start gap-2 rounded bg-gray-50 px-2 py-1 text-xs", - div { - class: "min-w-0", - div { - class: "flex items-center gap-2", - span { class: "{dot_class}" } - span { class: "font-medium text-gray-900 truncate", "{segment.name}" } - } - div { class: "font-mono text-gray-400", "span:{segment.span_id}" } - } - div { class: "font-mono text-gray-600", "{segment.phase}" } - div { - class: "min-w-0", - div { class: "font-mono text-gray-600", "{start} -> {end} | {duration}" } - div { class: "truncate text-gray-400", "{segment.process_label}" } - if let Some(ref attrs) = segment.attributes { - if !attrs.is_empty() { - div { class: "mt-0.5 truncate font-mono text-gray-400", "{attrs}" } - } - } - } - } - } - } - } - } - } -} - -#[component] -fn RlPhaseLegend() -> Element { - rsx! { - div { - class: "mb-3 flex flex-wrap items-center gap-3 rounded border border-gray-100 bg-gray-50 px-3 py-2 text-xs text-gray-600", - span { class: "font-medium text-gray-700", "Phase legend" } - RlLegendItem { class_name: "bg-blue-500".to_string(), label: "inference".to_string() } - RlLegendItem { class_name: "bg-amber-500".to_string(), label: "env step".to_string() } - RlLegendItem { class_name: "bg-violet-500".to_string(), label: "tool call".to_string() } - RlLegendItem { class_name: "bg-pink-500".to_string(), label: "reward".to_string() } - RlLegendItem { class_name: "bg-emerald-500".to_string(), label: "train/optimizer".to_string() } - RlLegendItem { class_name: "bg-slate-500".to_string(), label: "other".to_string() } - } - } -} - -#[component] -fn RlLegendItem(class_name: String, label: String) -> Element { - rsx! { - span { - class: "inline-flex items-center gap-1.5", - span { class: "h-2.5 w-2.5 rounded {class_name}" } - "{label}" - } - } -} - -#[component] -fn RlSampleDetail(sample: RlSampleRow, min_start: i64) -> Element { - let sample_duration = format_duration_ns(sample_duration_ns(&sample)); - rsx! { - ResizableDetailPanel { - title: format!("Trajectory Detail: {}", sample.label), - subtitle: format!("{} | duration {}", sample.detail, sample_duration), - segment_count: sample.segments.len(), - for segment in sample.segments.iter() { - { - let start = format_duration_ns(segment.start - min_start); - let end = format_duration_ns(segment.end - min_start); - let duration = format_duration_ns((segment.end - segment.start).max(0)); - let turn = segment.turn_id.clone().unwrap_or_else(|| "-".to_string()); - let env_step = segment.env_step_id.clone().unwrap_or_else(|| "-".to_string()); - let dot_class = rl_phase_dot_class(&segment.phase); - rsx! { - div { - class: "grid grid-cols-[minmax(180px,22%)_130px_90px_90px_1fr] items-start gap-2 rounded bg-gray-50 px-2 py-1 text-xs", - div { - class: "min-w-0", - div { - class: "flex items-center gap-2", - span { class: "{dot_class}" } - span { class: "font-medium text-gray-900 truncate", "{segment.name}" } - } - div { class: "font-mono text-gray-400", "span:{segment.span_id}" } - } - div { class: "font-mono text-gray-600", "{segment.phase}" } - div { class: "font-mono text-gray-500", "turn:{turn}" } - div { class: "font-mono text-gray-500", "env:{env_step}" } - div { - class: "min-w-0", - div { class: "font-mono text-gray-600", "{start} -> {end} | {duration}" } - div { class: "truncate text-gray-400", "{segment.process_label}" } - if let Some(ref attrs) = segment.attributes { - if !attrs.is_empty() { - div { class: "mt-0.5 truncate font-mono text-gray-400", "{attrs}" } - } - } - } - } - } - } - } - } - } -} - -fn build_timeline_spans( - spans: &[DisplaySpan], - max_depth: usize, - selected_batch_key: &str, -) -> Vec { - let mut rows = Vec::new(); - if !selected_batch_key.is_empty() { - if let Some(selected) = find_display_span(spans, selected_batch_key) { - collect_timeline_spans(std::slice::from_ref(selected), 0, max_depth, &mut rows); - } - } else { - collect_timeline_spans(spans, 0, max_depth, &mut rows); - } - rows.sort_by_key(|row| row.start); - rows -} - -fn build_rl_rollout_views(spans: &[DisplaySpan]) -> Vec { - let mut all_spans = Vec::::new(); - collect_all_display_spans(spans, &mut all_spans); - - let mut samples = HashMap::::new(); - - for span in all_spans { - let Some(segment) = rl_segment_from_span(&span) else { - continue; - }; - let Some(rollout_id) = attr_string(&span.attributes, "rollout_id") - .or_else(|| attr_string(&span.attributes, "step_id")) - else { - continue; - }; - let Some(trajectory_id) = attr_string(&span.attributes, "trajectory_id") - .or_else(|| { - let group_id = attr_string(&span.attributes, "group_id")?; - let group_index = attr_string(&span.attributes, "group_index")?; - Some(format!("r{rollout_id}-g{group_id}-i{group_index}")) - }) - .or_else(|| attr_string(&span.attributes, "sample_id")) - else { - continue; - }; - let sample_key = format!("{rollout_id}:{trajectory_id}"); - let entry = samples - .entry(sample_key.clone()) - .or_insert_with(|| RlSampleBuilder { - sample_key: sample_key.clone(), - label: trajectory_id.clone(), - rollout_id: rollout_id.clone(), - group_id: attr_string(&span.attributes, "group_id"), - attempt: attr_string(&span.attributes, "attempt"), - start: segment.start, - end: segment.end, - ..Default::default() - }); - - entry.start = entry.start.min(segment.start); - entry.end = entry.end.max(segment.end); - if entry.group_id.is_none() { - entry.group_id = attr_string(&span.attributes, "group_id"); - } - if entry.attempt.is_none() { - entry.attempt = attr_string(&span.attributes, "attempt"); - } - entry.process_labels.insert(span.process_label.clone()); - entry.segments.push(segment); - } - - let mut by_rollout = HashMap::>::new(); - for mut builder in samples.into_values() { - builder.segments.sort_by_key(|segment| segment.start); - let mut labels = builder.process_labels.into_iter().collect::>(); - labels.sort(); - let mut detail_parts = Vec::new(); - if let Some(group_id) = builder.group_id.filter(|value| !value.is_empty()) { - detail_parts.push(format!("group:{group_id}")); - } - if let Some(attempt) = builder.attempt.filter(|value| !value.is_empty()) { - detail_parts.push(format!("attempt:{attempt}")); - } - detail_parts.push(format!("{} phases", builder.segments.len())); - by_rollout - .entry(builder.rollout_id.clone()) - .or_default() - .push(RlSampleRow { - sample_key: builder.sample_key, - label: builder.label, - detail: detail_parts.join(" "), - start: builder.start, - end: builder.end, - process_labels: labels.join(", "), - segments: builder.segments, - }); - } - - let mut rollouts = by_rollout - .into_iter() - .map(|(rollout_id, mut samples)| { - samples.sort_by_key(|sample| sample.start); - let start = samples.iter().map(|sample| sample.start).min().unwrap_or(0); - let end = samples - .iter() - .map(|sample| sample.end) - .max() - .unwrap_or(start); - RlRolloutView { - rollout_id, - start, - end, - samples, - } - }) - .collect::>(); - rollouts.sort_by(|a, b| { - numeric_string_sort_key(&a.rollout_id) - .cmp(&numeric_string_sort_key(&b.rollout_id)) - .then_with(|| a.rollout_id.cmp(&b.rollout_id)) - }); - rollouts -} - -fn build_train_rollout_views(spans: &[DisplaySpan]) -> Vec { - let mut all_spans = Vec::::new(); - collect_all_display_spans(spans, &mut all_spans); - - let mut batches = HashMap::::new(); - - for span in all_spans { - let Some(segment) = train_segment_from_span(&span) else { - continue; - }; - let Some(rollout_id) = attr_string(&span.attributes, "rollout_id") - .or_else(|| attr_string(&span.attributes, "step_id")) - else { - continue; - }; - let train_step_id = attr_string(&span.attributes, "train_step_id"); - let batch_id = attr_string(&span.attributes, "batch_id"); - let Some(batch_key_id) = train_step_id.clone().or_else(|| batch_id.clone()) else { - continue; - }; - - let batch_key = format!("{rollout_id}:{batch_key_id}"); - let label = match (train_step_id, batch_id) { - (Some(train_step_id), Some(batch_id)) => { - format!("train_step:{train_step_id} batch:{batch_id}") - } - (Some(train_step_id), None) => format!("train_step:{train_step_id}"), - (None, Some(batch_id)) => format!("batch:{batch_id}"), - (None, None) => batch_key_id, - }; - let entry = batches - .entry(batch_key.clone()) - .or_insert_with(|| RlSampleBuilder { - sample_key: batch_key.clone(), - label, - rollout_id: rollout_id.clone(), - group_id: attr_string(&span.attributes, "role"), - attempt: attr_string(&span.attributes, "rank"), - start: segment.start, - end: segment.end, - ..Default::default() - }); - - entry.start = entry.start.min(segment.start); - entry.end = entry.end.max(segment.end); - if entry.group_id.is_none() { - entry.group_id = attr_string(&span.attributes, "role"); - } - if entry.attempt.is_none() { - entry.attempt = attr_string(&span.attributes, "rank"); - } - entry.process_labels.insert(span.process_label.clone()); - entry.segments.push(segment); - } - - let mut by_rollout = HashMap::>::new(); - for mut builder in batches.into_values() { - builder.segments.sort_by_key(|segment| segment.start); - let mut labels = builder.process_labels.into_iter().collect::>(); - labels.sort(); - let mut detail_parts = Vec::new(); - if let Some(role) = builder.group_id.filter(|value| !value.is_empty()) { - detail_parts.push(format!("role:{role}")); - } - if let Some(rank) = builder.attempt.filter(|value| !value.is_empty()) { - detail_parts.push(format!("rank:{rank}")); - } - detail_parts.push(format!("{} phases", builder.segments.len())); - by_rollout - .entry(builder.rollout_id.clone()) - .or_default() - .push(RlSampleRow { - sample_key: builder.sample_key, - label: builder.label, - detail: detail_parts.join(" "), - start: builder.start, - end: builder.end, - process_labels: labels.join(", "), - segments: builder.segments, - }); - } - - let mut rollouts = by_rollout - .into_iter() - .map(|(rollout_id, mut samples)| { - samples.sort_by_key(|sample| sample.start); - let start = samples.iter().map(|sample| sample.start).min().unwrap_or(0); - let end = samples - .iter() - .map(|sample| sample.end) - .max() - .unwrap_or(start); - RlRolloutView { - rollout_id, - start, - end, - samples, - } - }) - .collect::>(); - rollouts.sort_by(|a, b| { - numeric_string_sort_key(&a.rollout_id) - .cmp(&numeric_string_sort_key(&b.rollout_id)) - .then_with(|| a.rollout_id.cmp(&b.rollout_id)) - }); - rollouts -} - -fn collect_all_display_spans(spans: &[DisplaySpan], out: &mut Vec) { - for span in spans { - out.push(span.clone()); - collect_all_display_spans(&span.children, out); - } -} - -fn build_chrome_trace_json_from_display_spans(spans: &[DisplaySpan]) -> Option { - let mut all_spans = Vec::::new(); - collect_all_display_spans(spans, &mut all_spans); - let mut complete_spans = all_spans - .into_iter() - .filter(|span| span.end_timestamp.is_some()) - .collect::>(); - if complete_spans.is_empty() { - return None; - } - complete_spans.sort_by(|a, b| { - a.start_timestamp.cmp(&b.start_timestamp).then_with(|| { - let a_duration = a.end_timestamp.unwrap_or(a.start_timestamp) - a.start_timestamp; - let b_duration = b.end_timestamp.unwrap_or(b.start_timestamp) - b.start_timestamp; - b_duration.cmp(&a_duration) - }) - }); - - let min_start = complete_spans - .iter() - .map(|span| span.start_timestamp) - .min() - .unwrap_or(0); - - let mut process_labels = complete_spans - .iter() - .map(perfetto_process_label) - .collect::>() - .into_iter() - .collect::>(); - process_labels.sort(); - let process_ids = process_labels - .iter() - .enumerate() - .map(|(idx, label)| (label.clone(), (idx + 1) as i64)) - .collect::>(); - - let track_keys = complete_spans - .iter() - .map(|span| (perfetto_process_label(span), perfetto_track_label(span))) - .collect::>() - .into_iter() - .collect::>(); - let mut track_keys = track_keys; - track_keys.sort(); - let track_ids = track_keys - .iter() - .enumerate() - .map(|(idx, key)| (key.clone(), (idx + 1) as i64)) - .collect::>(); - - let mut trace_events = Vec::::new(); - for label in process_labels { - let pid = *process_ids.get(&label).unwrap_or(&1); - trace_events.push(serde_json::json!({ - "name": "process_name", - "ph": "M", - "pid": pid, - "tid": 0, - "args": { - "name": label, - }, - })); - } - for (process_label, track_label) in track_keys { - let pid = *process_ids.get(&process_label).unwrap_or(&1); - let tid = *track_ids - .get(&(process_label.clone(), track_label.clone())) - .unwrap_or(&1); - trace_events.push(serde_json::json!({ - "name": "thread_name", - "ph": "M", - "pid": pid, - "tid": tid, - "args": { - "name": track_label, - }, - })); - } - - for span in complete_spans { - let end = span.end_timestamp.unwrap_or(span.start_timestamp); - let duration = (end - span.start_timestamp).max(0) / 1000; - let ts = (span.start_timestamp - min_start).max(0) / 1000; - let process_label = perfetto_process_label(&span); - let pid = *process_ids.get(&process_label).unwrap_or(&1); - let track_label = perfetto_track_label(&span); - let tid = *track_ids - .get(&(process_label.clone(), track_label.clone())) - .unwrap_or(&span.thread_id); - let mut args = serde_json::Map::new(); - args.insert("span_id".to_string(), serde_json::json!(span.span_id)); - args.insert("trace_id".to_string(), serde_json::json!(span.trace_id)); - args.insert("process".to_string(), serde_json::json!(span.process_label)); - args.insert( - "perfetto_process".to_string(), - serde_json::json!(process_label), - ); - args.insert("track".to_string(), serde_json::json!(track_label)); - if let Some(parent_id) = span.parent_id { - args.insert("parent_id".to_string(), serde_json::json!(parent_id)); - } - if let Some(location) = span.location.as_ref().filter(|value| !value.is_empty()) { - args.insert("location".to_string(), serde_json::json!(location)); - } - if let Some(attrs) = span.attributes.as_ref().filter(|value| !value.is_empty()) { - if let Ok(parsed) = serde_json::from_str::(attrs) { - args.insert("attributes".to_string(), parsed); - } - } - - trace_events.push(serde_json::json!({ - "name": span.name, - "cat": span.kind.unwrap_or_else(|| "span".to_string()), - "ph": "X", - "ts": ts, - "dur": duration, - "pid": pid, - "tid": tid, - "args": args, - })); - } - - Some( - serde_json::json!({ - "traceEvents": trace_events, - "displayTimeUnit": "ms", - }) - .to_string(), - ) -} - -fn perfetto_process_label(span: &DisplaySpan) -> String { - if let Some(rollout_id) = attr_string(&span.attributes, "rollout_id") { - return format!("rollout:{rollout_id}"); - } - span.process_label.clone() -} - -fn perfetto_track_label(span: &DisplaySpan) -> String { - let rollout_id = attr_string(&span.attributes, "rollout_id"); - let trajectory_id = attr_string(&span.attributes, "trajectory_id") - .or_else(|| attr_string(&span.attributes, "sample_id")) - .or_else(|| { - let group_id = attr_string(&span.attributes, "group_id")?; - let group_index = attr_string(&span.attributes, "group_index")?; - Some(format!("group:{group_id}/sample:{group_index}")) - }); - - if let Some(rollout_id) = rollout_id { - if let Some(trajectory_id) = trajectory_id { - return format!("rollout:{rollout_id} / {trajectory_id}"); - } - if span.name.starts_with("rollout.") - || span.kind.as_deref().unwrap_or("").contains("rollout") - { - return format!("rollout:{rollout_id} / control"); - } - return format!("rollout:{rollout_id}"); - } - - if let Some(batch_id) = attr_string(&span.attributes, "batch_id") - .or_else(|| attr_string(&span.attributes, "train_step_id")) - { - return format!("train:{batch_id}"); - } - - format!("{} / thread:{}", span.process_label, span.thread_id) -} - -fn rl_segment_from_span(span: &DisplaySpan) -> Option { - let end = span.end_timestamp?; - if !has_trajectory_identity(&span.attributes) { - return None; - } - let phase = attr_string(&span.attributes, "phase") - .or_else(|| span.kind.clone()) - .unwrap_or_else(|| span.name.clone()); - - Some(RlPhaseSegment { - name: span.name.clone(), - phase, - start: span.start_timestamp, - end, - process_label: span.process_label.clone(), - span_id: span.span_id, - attributes: span.attributes.clone(), - turn_id: attr_string(&span.attributes, "turn_id"), - env_step_id: attr_string(&span.attributes, "env_step_id"), - }) -} - -fn train_segment_from_span(span: &DisplaySpan) -> Option { - let end = span.end_timestamp?; - let phase = attr_string(&span.attributes, "phase") - .or_else(|| span.kind.clone()) - .unwrap_or_else(|| span.name.clone()); - if !is_train_timeline_span(&span.name, &phase, span.kind.as_deref(), &span.attributes) { - return None; - } - - Some(RlPhaseSegment { - name: span.name.clone(), - phase, - start: span.start_timestamp, - end, - process_label: span.process_label.clone(), - span_id: span.span_id, - attributes: span.attributes.clone(), - turn_id: None, - env_step_id: None, - }) -} - -fn numeric_string_sort_key(value: &str) -> i64 { - value.parse::().unwrap_or(i64::MAX) -} - -fn sample_duration_ns(sample: &RlSampleRow) -> i64 { - (sample.end - sample.start).max(0) -} - -fn rl_phase_bar_class(phase: &str) -> &'static str { - if phase.contains("inference") || phase.contains("generate") { - "absolute top-1 bottom-1 rounded bg-blue-500" - } else if phase.contains("env") { - "absolute top-1 bottom-1 rounded bg-amber-500" - } else if phase.contains("tool") { - "absolute top-1 bottom-1 rounded bg-violet-500" - } else if phase.contains("reward") { - "absolute top-1 bottom-1 rounded bg-pink-500" - } else if phase.contains("train") || phase.contains("optimizer") { - "absolute top-1 bottom-1 rounded bg-emerald-500" - } else { - "absolute top-1 bottom-1 rounded bg-slate-500" - } -} - -fn rl_phase_dot_class(phase: &str) -> &'static str { - if phase.contains("inference") || phase.contains("generate") { - "h-2.5 w-2.5 flex-shrink-0 rounded-full bg-blue-500" - } else if phase.contains("env") { - "h-2.5 w-2.5 flex-shrink-0 rounded-full bg-amber-500" - } else if phase.contains("tool") { - "h-2.5 w-2.5 flex-shrink-0 rounded-full bg-violet-500" - } else if phase.contains("reward") { - "h-2.5 w-2.5 flex-shrink-0 rounded-full bg-pink-500" - } else if phase.contains("train") || phase.contains("optimizer") { - "h-2.5 w-2.5 flex-shrink-0 rounded-full bg-emerald-500" - } else { - "h-2.5 w-2.5 flex-shrink-0 rounded-full bg-slate-500" - } -} - -fn collect_timeline_spans( - spans: &[DisplaySpan], - depth: usize, - max_depth: usize, - rows: &mut Vec, -) { - for span in spans { - if depth < max_depth { - if let Some(end) = span.end_timestamp { - rows.push(TimelineSpan { - unique_key: span.unique_key.clone(), - name: span.name.clone(), - display_id: span.display_id.clone(), - span_id: span.span_id, - kind: span.kind.clone(), - location: span.location.clone(), - start: span.start_timestamp, - duration: (end - span.start_timestamp).max(0), - depth, - process_label: span.process_label.clone(), - }); - } - } - collect_timeline_spans(&span.children, depth + 1, max_depth, rows); - } -} - -fn build_display_spans(spans: &[SpanInfo]) -> Vec { - let mut counters = HashMap::::new(); - let mut sorted = spans.to_vec(); - sorted.sort_by_key(|span| span.start_timestamp); - sorted - .iter() - .map(|span| build_display_span(span, &mut counters)) - .collect() -} - -fn build_display_span(span: &SpanInfo, counters: &mut HashMap) -> DisplaySpan { - let counter_key = span - .phase - .as_ref() - .map(|phase| format!("{}:{phase}", span.name)) - .unwrap_or_else(|| span.name.clone()); - let next_id = counters.entry(counter_key).or_insert(0); - *next_id += 1; - let display_id = format!("{}#{}", span.name, *next_id); - let process_label = process_label(&span.attributes, span.trace_id); - let unique_key = format!("{}:{}:{}", process_label, span.trace_id, span.span_id); - - let mut children = span.children.clone(); - children.sort_by_key(|child| child.start_timestamp); - let children = children - .iter() - .map(|child| build_display_span(child, counters)) - .collect(); - - DisplaySpan { - unique_key, - span_id: span.span_id, - trace_id: span.trace_id, - parent_id: span.parent_id, - name: span.name.clone(), - display_id, - start_timestamp: span.start_timestamp, - end_timestamp: span.end_timestamp, - thread_id: span.thread_id, - kind: span.phase.clone(), - location: span.location.clone(), - attributes: span.attributes.clone(), - process_label, - children, - events: span.events.clone(), - } -} - -fn collect_process_options( - spans: &[DisplaySpan], - discovered_processes: &[TraceProcessInfo], -) -> Vec { - let mut by_pid = HashMap::::new(); - let mut all_spans = Vec::::new(); - collect_all_display_spans(spans, &mut all_spans); - - for process in discovered_processes { - by_pid - .entry(process.pid.to_string()) - .or_insert(FilterOption { - filter_key: format!("pid:{}", process.pid), - label: trace_process_label(process), - }); - } - - for span in all_spans { - let Some(pid) = process_pid_from_attrs(&span.attributes) else { - continue; - }; - let role = - attr_string(&span.attributes, "process_role").unwrap_or_else(|| "process".to_string()); - let actor_name = attr_string(&span.attributes, "ray_actor_name"); - let worker_id = attr_string(&span.attributes, "ray_worker_id").map(|id| short_id(&id)); - let mut parts = vec![format!("{role}:{pid}")]; - if let Some(actor_name) = actor_name.filter(|value| !value.is_empty()) { - parts.push(actor_name); - } - if let Some(worker_id) = worker_id.filter(|value| !value.is_empty()) { - parts.push(format!("worker:{worker_id}")); - } - by_pid.entry(pid.clone()).or_insert(FilterOption { - filter_key: format!("pid:{pid}"), - label: parts.join(" | "), - }); - } - - let mut options = by_pid.into_values().collect::>(); - options.sort_by(|a, b| { - process_option_rank(a) - .cmp(&process_option_rank(b)) - .then_with(|| a.label.cmp(&b.label)) - }); - options -} - -fn trace_process_label(process: &TraceProcessInfo) -> String { - let role = process.process_role.as_deref().unwrap_or("process"); - let mut parts = vec![format!("{role}:{}", process.pid)]; - if let Some(actor_name) = process - .ray_actor_name - .as_ref() - .filter(|value| !value.is_empty()) - { - parts.push(actor_name.clone()); - } - if let Some(worker_id) = process - .ray_worker_id - .as_ref() - .filter(|value| !value.is_empty()) - { - parts.push(format!("worker:{}", short_id(worker_id))); - } - parts.join(" | ") -} - -fn collect_function_options(spans: &[DisplaySpan]) -> Vec { - let mut counts = HashMap::::new(); - let mut all_spans = Vec::::new(); - collect_all_display_spans(spans, &mut all_spans); - - for span in all_spans { - *counts.entry(span.name).or_insert(0) += 1; - } - - let mut options = counts - .into_iter() - .map(|(name, count)| FilterOption { - filter_key: name.clone(), - label: format!("{name} ({count})"), - }) - .collect::>(); - options.sort_by(|a, b| a.filter_key.cmp(&b.filter_key)); - options -} - -fn filter_display_spans( - spans: &[DisplaySpan], - process_filter: &str, - function_filter: &str, -) -> Vec { - let mut filtered = Vec::new(); - collect_filtered_display_spans(spans, process_filter, function_filter, &mut filtered); - if filtered.is_empty() && process_filter == "driver" && function_filter.is_empty() { - spans.to_vec() - } else { - filtered - } -} - -fn collect_filtered_display_spans( - spans: &[DisplaySpan], - process_filter: &str, - function_filter: &str, - out: &mut Vec, -) { - for span in spans { - let mut children = Vec::new(); - collect_filtered_display_spans( - &span.children, - process_filter, - function_filter, - &mut children, - ); - - if process_filter_matches(span, process_filter) - && function_filter_matches(span, function_filter) - { - let mut cloned = span.clone(); - cloned.children = children; - out.push(cloned); - } else { - out.extend(children); - } - } -} - -fn process_filter_matches(span: &DisplaySpan, process_filter: &str) -> bool { - if process_filter.is_empty() || process_filter == "all" { - return true; - } - if process_filter == "driver" { - return attr_string(&span.attributes, "process_role") - .map(|role| role.contains("driver")) - .unwrap_or(false); - } - if let Some(pid_filter) = process_filter.strip_prefix("pid:") { - return process_pid_from_attrs(&span.attributes).as_deref() == Some(pid_filter); - } - false -} - -fn function_filter_matches(span: &DisplaySpan, function_filter: &str) -> bool { - function_filter.is_empty() || span.name == function_filter -} - -fn collect_span_process_pids(spans: &[SpanInfo]) -> HashSet { - let mut pids = HashSet::new(); - for span in spans { - if let Some(pid) = - process_pid_from_attrs(&span.attributes).and_then(|pid| pid.parse::().ok()) - { - pids.insert(pid); - } - pids.extend(collect_span_process_pids(&span.children)); - } - pids -} - -fn process_pid_from_attrs(attributes: &Option) -> Option { - attr_string(attributes, "pid").filter(|pid| !pid.is_empty() && pid != "0") -} - -fn short_id(value: &str) -> String { - if value.len() > 8 { - value[..8].to_string() - } else { - value.to_string() - } -} - -fn process_option_rank(option: &FilterOption) -> usize { - if option.label.contains("driver") { - 0 - } else if option.label.contains("rollout") { - 1 - } else { - 2 - } -} - -fn collect_batch_options(spans: &[DisplaySpan]) -> Vec { - let mut rows = Vec::new(); - collect_batches(spans, &mut rows); - rows.sort_by_key(|row| row.start); - rows -} - -fn collect_batches(spans: &[DisplaySpan], rows: &mut Vec) { - for span in spans { - let is_batch = - span.name == "batch" || is_step_parent_span(&span.name, span.kind.as_deref()); - if is_batch { - let duration = span - .end_timestamp - .map(|end| (end - span.start_timestamp).max(0)) - .unwrap_or(0); - rows.push(TimelineSpan { - unique_key: span.unique_key.clone(), - name: span.name.clone(), - display_id: span.display_id.clone(), - span_id: span.span_id, - kind: span.kind.clone(), - location: span.location.clone(), - start: span.start_timestamp, - duration, - depth: 0, - process_label: span.process_label.clone(), - }); - } - collect_batches(&span.children, rows); - } -} - -fn find_display_span<'a>(spans: &'a [DisplaySpan], unique_key: &str) -> Option<&'a DisplaySpan> { - for span in spans { - if span.unique_key == unique_key { - return Some(span); - } - if let Some(found) = find_display_span(&span.children, unique_key) { - return Some(found); - } - } - None -} - -fn cross_process_link_summary_view(spans: &[DisplaySpan]) -> CrossProcessLinkSummaryView { - let mut summary = CrossProcessLinkSummaryView::default(); - let mut orphan_keys = HashMap::::new(); - collect_cross_process_link_summary(spans, false, &mut summary, &mut orphan_keys); - let mut orphan_keys = orphan_keys.into_iter().collect::>(); - orphan_keys.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); - summary.orphan_keys = orphan_keys - .into_iter() - .take(3) - .map(|(key, count)| format!("{key}:{count}")) - .collect::>() - .join(", "); - summary -} - -fn collect_cross_process_link_summary( - spans: &[DisplaySpan], - under_logical_parent: bool, - summary: &mut CrossProcessLinkSummaryView, - orphan_keys: &mut HashMap, -) { - for span in spans { - let is_parent = is_display_cross_process_parent_span(span); - let under_parent = under_logical_parent || is_parent; - if is_parent { - summary.parents += 1; - } - - if is_display_cross_process_child_candidate(span) { - if under_logical_parent { - summary.linked += 1; - } else { - summary.orphan += 1; - if let Some(key) = display_logical_step_key(span) { - let key = format!("{}/{}", key.rollout_id, key.step_id); - *orphan_keys.entry(key).or_insert(0) += 1; - } - } - } - - collect_cross_process_link_summary(&span.children, under_parent, summary, orphan_keys); - } -} - -fn is_display_logical_parent_span(span: &DisplaySpan) -> bool { - is_step_parent_span(&span.name, span.kind.as_deref()) - && !is_rollout_submit_parent_span(&span.name) -} - -fn is_display_submit_parent_span(span: &DisplaySpan) -> bool { - is_rollout_submit_parent_span(&span.name) -} - -fn is_display_cross_process_parent_span(span: &DisplaySpan) -> bool { - is_display_logical_parent_span(span) || is_display_submit_parent_span(span) -} - -fn is_display_cross_process_child_candidate(span: &DisplaySpan) -> bool { - if display_logical_step_key(span).is_none() || is_display_cross_process_parent_span(span) { - return false; - } - - let role = attr_string(&span.attributes, "process_role") - .or_else(|| attr_string(&span.attributes, "actor_role")) - .unwrap_or_default(); - is_rollout_worker_role(&role) || span.name.starts_with("custom.generate") -} - -fn display_logical_step_key(span: &DisplaySpan) -> Option { - let rollout_id = attr_string(&span.attributes, "rollout_id")?; - let step_id = attr_string(&span.attributes, "step_id")?; - - if rollout_id == "-1" || step_id == "-1" { - return None; - } - - Some(LogicalStepKey { - rollout_id, - step_id, - }) -} - -fn expanded_trace_fetch_limit(limit: usize) -> usize { - limit.saturating_mul(5).min(10_000).max(limit) -} - -fn link_cross_process_spans(spans: Vec) -> Vec { - let mut linked_roots = Vec::new(); - let mut pending_children = Vec::new(); - - split_cross_process_candidates(spans, &mut linked_roots, &mut pending_children); - - pending_children.sort_by_key(|span| span.start_timestamp); - for child in pending_children { - if let Some(key) = logical_step_key(&child) { - if attach_to_logical_parent(&mut linked_roots, &key, child.clone()) { - continue; - } - } - if let Some(key) = submit_link_key(&child) { - if attach_to_submit_parent(&mut linked_roots, &key, child.clone()) { - continue; - } - } - if attach_to_time_parent(&mut linked_roots, child.clone()) { - continue; - } - if let Some(rollout_id) = logical_rollout_id(&child) { - if attach_to_single_rollout_parent(&mut linked_roots, &rollout_id, child.clone()) { - continue; - } - } - linked_roots.push(child); - } - - sort_span_tree(&mut linked_roots); - linked_roots -} - -fn split_cross_process_candidates( - spans: Vec, - linked_roots: &mut Vec, - pending_children: &mut Vec, -) { - for mut span in spans { - if rl_contract::is_cross_process_child_candidate(&span) { - pending_children.push(span); - continue; - } - - let children = std::mem::take(&mut span.children); - split_cross_process_candidates(children, &mut span.children, pending_children); - linked_roots.push(span); - } -} - -fn is_logical_parent_span(span: &SpanInfo) -> bool { - is_step_parent_span(&span.name, span.phase.as_deref()) - && !is_rollout_submit_parent_span(&span.name) -} - -fn is_submit_parent_span(span: &SpanInfo) -> bool { - is_rollout_submit_parent_span(&span.name) -} - -fn submit_link_key(span: &SpanInfo) -> Option { - let rollout_id = attr_string(&span.attributes, "rollout_id")?; - let submit_step_id = attr_string(&span.attributes, "submit_step_id")?; - - if rollout_id == "-1" { - return None; - } - - Some(LogicalStepKey { - rollout_id, - step_id: submit_step_id, - }) -} - -fn logical_rollout_id(span: &SpanInfo) -> Option { - let rollout_id = attr_string(&span.attributes, "rollout_id")?; - if rollout_id == "-1" { - None - } else { - Some(rollout_id) - } -} - -fn attach_to_logical_parent(spans: &mut [SpanInfo], key: &LogicalStepKey, child: SpanInfo) -> bool { - for span in spans { - if is_logical_parent_span(span) && logical_step_key(span).as_ref() == Some(key) { - span.children.push(child); - span.children.sort_by_key(|child| child.start_timestamp); - return true; - } - - if attach_to_logical_parent(&mut span.children, key, child.clone()) { - return true; - } - } - false -} - -fn attach_to_submit_parent(spans: &mut [SpanInfo], key: &LogicalStepKey, child: SpanInfo) -> bool { - for span in spans { - if is_submit_parent_span(span) && submit_parent_matches(span, key) { - span.children.push(child); - span.children.sort_by_key(|child| child.start_timestamp); - return true; - } - - if attach_to_submit_parent(&mut span.children, key, child.clone()) { - return true; - } - } - false -} - -fn submit_parent_matches(span: &SpanInfo, key: &LogicalStepKey) -> bool { - let rollout_id = attr_string(&span.attributes, "rollout_id"); - let submit_step_id = attr_string(&span.attributes, "submit_step_id") - .or_else(|| attr_string(&span.attributes, "step_id")); - rollout_id.as_deref() == Some(key.rollout_id.as_str()) - && submit_step_id.as_deref() == Some(key.step_id.as_str()) -} - -fn attach_to_single_rollout_parent( - spans: &mut [SpanInfo], - rollout_id: &str, - child: SpanInfo, -) -> bool { - let mut matches = Vec::>::new(); - let mut current_path = Vec::::new(); - find_rollout_parent_paths(spans, rollout_id, &mut current_path, &mut matches); - - if matches.len() != 1 { - return false; - } - - if let Some(parent) = get_span_mut_by_path(spans, &matches[0]) { - parent.children.push(child); - parent.children.sort_by_key(|child| child.start_timestamp); - return true; - } - - false -} - -fn find_rollout_parent_paths( - spans: &[SpanInfo], - rollout_id: &str, - current_path: &mut Vec, - matches: &mut Vec>, -) { - for (index, span) in spans.iter().enumerate() { - current_path.push(index); - - if is_logical_parent_span(span) && logical_rollout_id(span).as_deref() == Some(rollout_id) { - matches.push(current_path.clone()); - } - - find_rollout_parent_paths(&span.children, rollout_id, current_path, matches); - current_path.pop(); - } -} - -fn attach_to_time_parent(spans: &mut [SpanInfo], child: SpanInfo) -> bool { - let mut best_path = Vec::::new(); - let mut current_path = Vec::::new(); - let mut best_duration: Option = None; - find_time_parent_path( - spans, - &child, - &mut current_path, - &mut best_path, - &mut best_duration, - ); - - if best_path.is_empty() { - return false; - } - - if let Some(parent) = get_span_mut_by_path(spans, &best_path) { - parent.children.push(child); - parent.children.sort_by_key(|child| child.start_timestamp); - return true; - } - - false -} - -fn find_time_parent_path( - spans: &[SpanInfo], - child: &SpanInfo, - current_path: &mut Vec, - best_path: &mut Vec, - best_duration: &mut Option, -) { - for (index, span) in spans.iter().enumerate() { - current_path.push(index); - - if is_logical_parent_span(span) && span_contains_child(span, child) { - let duration = span - .end_timestamp - .map(|end| end - span.start_timestamp) - .unwrap_or(i64::MAX); - if best_duration.map(|best| duration < best).unwrap_or(true) { - *best_duration = Some(duration); - *best_path = current_path.clone(); - } - } - - find_time_parent_path( - &span.children, - child, - current_path, - best_path, - best_duration, - ); - current_path.pop(); - } -} - -fn span_contains_child(parent: &SpanInfo, child: &SpanInfo) -> bool { - let child_end = child.end_timestamp.unwrap_or(child.start_timestamp); - let parent_end = parent.end_timestamp.unwrap_or(i64::MAX); - child.start_timestamp >= parent.start_timestamp && child_end <= parent_end -} - -fn get_span_mut_by_path<'a>(spans: &'a mut [SpanInfo], path: &[usize]) -> Option<&'a mut SpanInfo> { - let (first, rest) = path.split_first()?; - let span = spans.get_mut(*first)?; - if rest.is_empty() { - Some(span) - } else { - get_span_mut_by_path(&mut span.children, rest) - } -} - -fn sort_span_tree(spans: &mut [SpanInfo]) { - spans.sort_by_key(|span| span.start_timestamp); - for span in spans { - sort_span_tree(&mut span.children); - } -} - -fn process_label(attributes: &Option, fallback_trace_id: i64) -> String { - let role = attr_string(attributes, "process_role").unwrap_or_else(|| "process".to_string()); - let pid = attr_string(attributes, "pid"); - match pid { - Some(pid) if !pid.is_empty() => format!("{role}:{pid}"), - _ => format!("{role}:{fallback_trace_id}"), - } -} - -fn attr_string(attributes: &Option, key: &str) -> Option { - let attrs = attributes.as_ref()?; - let value = serde_json::from_str::(attrs).ok()?; - let raw = value.get(key)?; - match raw { - serde_json::Value::String(s) => Some(s.clone()), - serde_json::Value::Number(n) => Some(n.to_string()), - serde_json::Value::Bool(b) => Some(b.to_string()), - _ => None, - } -} - -fn format_duration_ns(duration_ns: i64) -> String { - let duration_ns = duration_ns.max(0) as f64; - if duration_ns >= 1_000_000_000.0 { - format!("{:.3}s", duration_ns / 1_000_000_000.0) - } else if duration_ns >= 1_000_000.0 { - format!("{:.3}ms", duration_ns / 1_000_000.0) - } else if duration_ns >= 1_000.0 { - format!("{:.3}us", duration_ns / 1_000.0) - } else { - format!("{:.0}ns", duration_ns) - } -} - -#[component] -fn EventView(event: EventInfo) -> Element { - rsx! { - div { - class: "flex items-center gap-2 py-1 text-sm", - span { - class: "text-gray-400", - "•" - } - span { - class: "text-gray-700", - "{event.name}" - } - if let Some(ref attrs) = event.attributes { - if !attrs.is_empty() { - span { - class: "text-xs text-gray-500 font-mono", - "{attrs}" - } - } - } - } - } -} - -mod inference; -pub use inference::Inference; diff --git a/web/src/pages/stack.rs b/web/src/pages/stack.rs deleted file mode 100644 index c832229e..00000000 --- a/web/src/pages/stack.rs +++ /dev/null @@ -1,477 +0,0 @@ -use dioxus::prelude::*; -use dioxus_router::Link; -use probing_proto::prelude::CallFrame; - -use crate::api::ApiClient; -use crate::app::Route; -use crate::components::callstack_view::CallStackView; -use crate::components::common::{AsyncBoundary, EmptyState, ErrorState}; -use crate::components::flamegraph::{FlamegraphPayload, FlamegraphView}; -use crate::components::page::{PageContainer, PageTitle}; -use crate::components::profiling::{ProfilingContentPanel, ProfilingErrorPanel}; -use crate::hooks::use_app_resource; -use crate::state::stack::{ - stack_tid_label, StackSnapshot, STACK_DIST_CLUSTER, STACK_DIST_RELOAD, STACK_MODE, - STACK_REFRESH, STACK_SNAPSHOT, -}; -use crate::utils::callframe::{ - classify_frame, count_by_kind, frame_location, frame_matches_query, frame_title, matches_mode, - stack_evidence, -}; -use crate::utils::error::AppError; - -#[component] -pub fn Stack(tid: Option) -> Element { - let tid_for_api = tid.clone(); - let tid_label = stack_tid_label(tid.as_deref()); - let refresh_tick = STACK_REFRESH(); - - rsx! { - PageContainer { - PageTitle { - title: "Stacks".to_string(), - subtitle: Some(format!( - "One live root-to-current call path · thread {tid_label}" - )), - icon: Some(&icondata::AiApartmentOutlined), - } - - AsyncBoundary { - message: Some("Loading call stack…".to_string()), - StackLoaded { - tid: tid_for_api, - tid_label: tid_label, - refresh_tick: refresh_tick, - } - } - } - } -} - -#[component] -fn StackLoaded(tid: Option, tid_label: String, refresh_tick: u32) -> Element { - let mode = STACK_MODE(); - let filter_mode = mode.clone(); - let mut query = use_signal(String::new); - let stack = use_app_resource(move || { - let _ = refresh_tick; - let tid_arg = tid.clone(); - async move { - ApiClient::new() - .get_callstack_with_mode(tid_arg, "mixed") - .await - } - }); - - let stack_peek = stack.read().clone(); - let tid_for_effect = tid_label.clone(); - - use_effect(use_reactive!(|( - mode, - refresh_tick, - stack_peek, - tid_for_effect, - )| { - let _ = refresh_tick; - let Some(result) = stack_peek.as_ref() else { - return; - }; - *STACK_SNAPSHOT.write() = stack_snapshot_for(&tid_for_effect, result, &mode); - })); - - match stack.suspend()?().as_ref() { - Err(err) => rsx! { - ErrorState { - title: Some("Failed to load stack".to_string()), - error: err.display_message(), - } - }, - Ok(callframes) if callframes.is_empty() => rsx! { - EmptyState { - message: format!( - "No stack frames for thread {tid_label}. The thread may be idle or not yet sampled." - ) - } - }, - Ok(callframes) => { - let current_mode = filter_mode.clone(); - let query_value = query.read().trim().to_string(); - let filtered: Vec<_> = callframes - .iter() - .enumerate() - .filter(|(_, frame)| matches_mode(frame, current_mode.as_str())) - .filter(|(_, frame)| frame_matches_query(frame, &query_value)) - .map(|(index, frame)| (index, frame.clone())) - .collect(); - let shown = filtered.len(); - let total = callframes.len(); - let current_is_visible = filtered.iter().any(|(index, _)| *index + 1 == total); - - rsx! { - div { class: "space-y-4", - StackSnapshotCard { - frames: callframes.clone(), - tid_label: tid_label.clone(), - mode: current_mode.clone(), - query: query_value.clone(), - } - section { class: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", - div { class: "flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 px-4 py-2.5", - div { - h2 { class: "text-sm font-semibold text-gray-950", "Call path" } - p { class: "mt-0.5 text-xs text-gray-500", - "{shown} of {total} frames · {mode_label(¤t_mode)}" - } - } - input { - r#type: "search", - class: "w-64 max-w-full rounded-lg border border-gray-300 px-3 py-1.5 text-xs text-gray-800 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20", - placeholder: "Find function or source file", - value: "{query_value}", - aria_label: "Find stack frame", - oninput: move |event| query.set(event.value()), - } - } - if filtered.is_empty() { - div { class: "p-4", - EmptyState { - message: if query_value.is_empty() { - format!("No {} frames in this capture", mode_label(¤t_mode)) - } else { - format!("No frames match \"{query_value}\"") - } - } - } - } else { - div { class: "p-3", - for (visible_index, (frame_index, frame)) in filtered.iter().enumerate() { - div { - id: "stack-frame-{frame_index}", - CallStackView { - key: "{refresh_tick}-{frame_index}", - callstack: frame.clone(), - index: *frame_index, - is_last: visible_index + 1 == shown, - default_open: *frame_index + 1 == total || (!current_is_visible && visible_index == 0), - position: frame_position(*frame_index, total), - } - } - } - } - } - } - } - } - } - } -} - -#[component] -fn StackSnapshotCard( - frames: Vec, - tid_label: String, - mode: String, - query: String, -) -> Element { - let evidence = stack_evidence(&frames); - let source_coverage = format!("{} / {}", evidence.source_frames, evidence.total); - let current = frames.last().cloned(); - - rsx! { - section { class: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", - div { class: "border-b border-gray-100 px-4 py-2.5", - h2 { class: "text-sm font-semibold text-gray-950", "Captured stack" } - p { class: "mt-0.5 text-xs text-gray-500", "Thread {tid_label} · one on-demand sample" } - } - div { class: "p-4 space-y-4", - div { class: "grid grid-cols-4 divide-x divide-gray-200", - StackMetric { label: "Frames", value: evidence.total.to_string() } - StackMetric { label: "Runtime boundaries", value: evidence.runtime_boundaries.to_string() } - StackMetric { label: "Source locations", value: source_coverage } - StackMetric { label: "Python locals", value: evidence.local_values.to_string() } - } - if let Some(current) = current { - CurrentFrameEvidence { frame: current, index: evidence.total.saturating_sub(1) } - } - StackOverview { frames, mode, query } - } - } - } -} - -#[component] -fn StackMetric(label: &'static str, value: String) -> Element { - rsx! { - div { class: "min-w-0 px-3 first:pl-0 last:pr-0", - div { class: "text-[10px] font-medium uppercase tracking-wide text-gray-500", "{label}" } - div { class: "mt-1 text-lg font-semibold tabular-nums text-gray-950", "{value}" } - } - } -} - -#[component] -fn CurrentFrameEvidence(frame: CallFrame, index: usize) -> Element { - let title = frame_title(&frame); - let kind = classify_frame(&frame); - let (kind_label, kind_class) = kind.status_badge(); - let location = frame_location(&frame) - .filter(|(file, _)| !file.is_empty()) - .map(|(file, line)| format!("{file}:{line}")); - - rsx! { - div { class: "flex min-w-0 items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2", - span { class: "shrink-0 text-[10px] font-medium uppercase tracking-wide text-gray-500", "Current frame" } - span { class: "shrink-0 font-mono text-[10px] tabular-nums text-gray-400", "#{index}" } - span { class: "min-w-0 flex-1 truncate font-mono text-xs font-medium text-gray-900", title: "{title}", "{title}" } - if let Some(location) = location { - span { class: "hidden max-w-[35%] truncate font-mono text-[10px] text-gray-500 xl:block", title: "{location}", "{location}" } - } - span { class: "shrink-0 rounded border px-1.5 py-0.5 text-[9px] font-semibold {kind_class}", "{kind_label}" } - } - } -} - -#[component] -fn StackOverview(frames: Vec, mode: String, query: String) -> Element { - let evidence = stack_evidence(&frames); - rsx! { - div { class: "space-y-2 border-t border-gray-100 pt-3", - div { class: "flex flex-wrap items-center justify-between gap-2 text-[10px] text-gray-500", - span { "Root → current · one cell per frame" } - div { class: "flex flex-wrap items-center gap-3", - StackLegend { class: "bg-emerald-500", label: format!("Python {}", evidence.python) } - StackLegend { class: "bg-orange-500", label: format!("Rust {}", evidence.rust) } - StackLegend { class: "bg-blue-500", label: format!("Native {}", evidence.native) } - } - } - div { class: "grid grid-flow-col auto-cols-fr gap-1 overflow-x-auto pb-1", - for (index, frame) in frames.iter().enumerate() { - { - let active = matches_mode(frame, &mode) && frame_matches_query(frame, &query); - let title = frame_overview_title(index, frame); - let classes = if active { - classify_frame(frame).overview_cell() - } else { - "bg-gray-200" - }; - rsx! { - if active { - a { - href: "#stack-frame-{index}", - class: "h-4 min-w-2 rounded-sm {classes} focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1", - title: "{title}", - aria_label: "Open frame {index}: {frame_title(frame)}", - } - } else { - span { class: "h-4 min-w-2 rounded-sm {classes}", title: "{title}" } - } - } - } - } - } - } - } -} - -#[component] -fn StackLegend(class: &'static str, label: String) -> Element { - rsx! { - span { class: "inline-flex items-center gap-1", - span { class: "h-2 w-2 rounded-sm {class}" } - "{label}" - } - } -} - -fn frame_overview_title(index: usize, frame: &CallFrame) -> String { - let location = frame_location(frame) - .filter(|(file, _)| !file.is_empty()) - .map(|(file, line)| format!(" · {file}:{line}")) - .unwrap_or_default(); - format!("#{index} · {}{location}", frame_title(frame)) -} - -fn frame_position(index: usize, total: usize) -> Option { - match (index == 0, index + 1 == total) { - (true, true) => Some("root · current".to_string()), - (true, false) => Some("root".to_string()), - (false, true) => Some("current".to_string()), - (false, false) => None, - } -} - -fn stack_snapshot_for( - tid_label: &str, - result: &Result, AppError>, - mode: &str, -) -> StackSnapshot { - match result { - Err(_) => StackSnapshot::default(), - Ok(frames) if frames.is_empty() => StackSnapshot { - tid_label: tid_label.to_string(), - loaded: true, - ..StackSnapshot::default() - }, - Ok(frames) => { - let (py_count, rust_count, cpp_count) = count_by_kind(frames); - let shown = frames.iter().filter(|cf| matches_mode(cf, mode)).count(); - StackSnapshot { - tid_label: tid_label.to_string(), - total: frames.len(), - py: py_count, - rust: rust_count, - cpp: cpp_count, - shown, - loaded: true, - } - } - } -} - -fn mode_label(mode: &str) -> &'static str { - match mode { - "py" => "Python", - "rust" => "Rust", - "cpp" => "Native", - _ => "All", - } -} - -/// Distributed stack flamegraph — merge identical stacks across ranks. -#[component] -pub fn StackDistributed(mode: String) -> Element { - let reload = *STACK_DIST_RELOAD.read(); - let cluster = *STACK_DIST_CLUSTER.read(); - let api_mode = if mode == "py" { "py" } else { "mixed" }; - let on_full = api_mode == "mixed"; - let scope = if cluster { - "cluster fan-out" - } else { - "this node" - }; - let subtitle = if api_mode == "py" { - format!("Python call paths merged by sample count · {scope}") - } else { - format!("Mixed-language call paths merged by sample count · {scope}") - }; - - rsx! { - PageContainer { - PageTitle { - title: "Distributed stacks".to_string(), - subtitle: Some(subtitle), - icon: Some(&icondata::AiClusterOutlined), - } - div { class: "flex flex-col flex-1 min-h-0 min-w-0 gap-3", - div { - class: "flex gap-1 border-b border-slate-700/80", - DistViewTab { - label: "Full stack", - active: on_full, - route: Route::StackDistributedFullPage {}, - } - DistViewTab { - label: "Python only", - active: !on_full, - route: Route::StackDistributedPyPage {}, - } - } - ProfilingContentPanel { - AsyncBoundary { - message: Some("Loading distributed flamegraph…".to_string()), - StackDistributedFlamegraph { - key: "stack-dist-{reload}-{cluster}-{api_mode}", - cluster, - mode: api_mode.to_string(), - } - } - } - } - } - } -} - -#[component] -fn DistViewTab(label: &'static str, active: bool, route: Route) -> Element { - let class = if active { - "px-3 py-2 text-sm font-medium border-b-2 border-blue-500 text-blue-200 -mb-px" - } else { - "px-3 py-2 text-sm text-slate-400 hover:text-slate-200 border-b-2 border-transparent -mb-px" - }; - rsx! { - Link { - to: route, - class: "{class}", - "{label}" - } - } -} - -#[component] -fn StackDistributedFlamegraph(cluster: bool, mode: String) -> Element { - let payload = use_app_resource(move || { - let mode = mode.clone(); - async move { - let body = ApiClient::new() - .get_distributed_stack_flamegraph_json(cluster, &mode) - .await?; - let parsed: FlamegraphPayload = serde_json::from_str(&body) - .map_err(|e| AppError::Api(format!("Invalid flamegraph JSON: {e}")))?; - Ok(parsed) - } - }); - - match payload.suspend()?() { - Ok(data) => { - let total = data.total; - let rank_count = data.rank_count; - let frame_count = data.frames.len(); - let nodes_failed = data.nodes_failed.clone(); - rsx! { - div { class: "flex min-h-0 flex-1 flex-col", - DistributedStackEvidence { total, rank_count, frame_count, nodes_failed } - FlamegraphView { - payload: data, - thread_tid: None, - torch_metric: None, - on_torch_metric: None, - } - } - } - } - Err(err) => rsx! { - ProfilingErrorPanel { - title: "Distributed stack flamegraph".to_string(), - error: err.display_message(), - } - }, - } -} - -#[component] -fn DistributedStackEvidence( - total: u64, - rank_count: Option, - frame_count: usize, - nodes_failed: Vec, -) -> Element { - let ranks = rank_count - .map(|count| count.to_string()) - .unwrap_or_else(|| "—".to_string()); - let failed_title = nodes_failed.join("\n"); - rsx! { - div { class: "border-b border-gray-200 bg-white px-4 py-3", - div { class: "grid grid-cols-4 divide-x divide-gray-200", - StackMetric { label: "Samples", value: total.to_string() } - StackMetric { label: "Ranks included", value: ranks } - StackMetric { label: "Merged frames", value: frame_count.to_string() } - StackMetric { label: "Failed peers", value: nodes_failed.len().to_string() } - } - if !nodes_failed.is_empty() { - p { class: "mt-2 truncate text-[10px] text-amber-700", title: "{failed_title}", - "Partial result · {nodes_failed.len()} peer(s) did not contribute" - } - } - } - } -} diff --git a/web/src/pages/traces.rs b/web/src/pages/traces.rs deleted file mode 100644 index db1e83ac..00000000 --- a/web/src/pages/traces.rs +++ /dev/null @@ -1,923 +0,0 @@ -use dioxus::prelude::*; -use dioxus_router::Link; - -use crate::api::{ApiClient, EventInfo, SpanInfo}; -use crate::app::Route; -use crate::components::card::Card; -use crate::components::colors::colors; -use crate::components::common::{query_result, AsyncBoundary, EmptyState}; -use crate::components::icon::Icon; -use crate::components::page::{PageContainer, PageTitle}; -use crate::components::poll_status::{ManualRefreshStatus, RefreshButton}; -use crate::components::span_timeline::{ - span_cover_pct, span_self_ns, span_total_ns, SpanSummaryBar, SpanSummaryHeader, - SpanSummarySpacer, SpanTimelineBar, SpanTimelineHeader, SpanTimelineLegend, SpanTimelineSpacer, - TraceTimeWindow, -}; -use crate::hooks::use_app_resource; -use crate::state::investigation::{ - clear_spans_investigation_filters, investigation_context_key, set_trace_context, - sync_spans_filters_to_context, InvestigationContext, INVESTIGATION_CONTEXT, -}; -use crate::state::profiling::{SPANS_TREE_LIMIT, SPANS_TREE_RELOAD}; - -const SPANS_LIMIT_MIN: usize = 100; -const SPANS_LIMIT_MAX: usize = 5000; -const SPANS_LIMIT_STEP: usize = 100; - -#[component] -pub fn Traces(#[props(default = true)] show_context_controls: bool) -> Element { - let mut refresh = use_signal(|| 0u32); - let refresh_tick = refresh().wrapping_add(*SPANS_TREE_RELOAD.read()); - let mut filter = use_signal(String::new); - let expand_all = use_signal(|| 0u32); - let collapse_all = use_signal(|| 0u32); - let mut trace_id_filter = use_signal(String::new); - let mut thread_filter = use_signal(String::new); - let min_ms_filter = use_signal(String::new); - let active_only = use_signal(|| false); - let mut show_advanced = use_signal(|| false); - let mut last_applied_ctx = use_signal(String::new); - let clear_filters_tick = use_signal(|| 0u32); - let summary_layout = use_signal(|| true); - - use_effect(move || { - let ctx = INVESTIGATION_CONTEXT.read().clone(); - let key = investigation_context_key(&ctx); - if key == last_applied_ctx() { - return; - } - last_applied_ctx.set(key); - trace_id_filter.set(ctx.trace_id.map(|t| t.to_string()).unwrap_or_default()); - // Apply thread filter when trace context is active, or when jumping from Dashboard - // with thread-only context (no span_name / local_step — avoids Stacks page tid bleed). - let thread_only = ctx.tid.is_some() - && ctx.trace_id.is_none() - && ctx.span_name.is_none() - && ctx.local_step.is_none(); - filter.set(ctx.span_name.unwrap_or_default()); - if ctx.trace_id.is_some() || thread_only { - thread_filter.set(ctx.tid.map(|t| t.to_string()).unwrap_or_default()); - show_advanced.set(true); - } else { - thread_filter.set(String::new()); - show_advanced.set(ctx.local_step.is_some()); - } - }); - - rsx! { - PageContainer { - PageTitle { - title: "Spans".to_string(), - subtitle: Some( - "Hierarchical spans aligned to the trace window — expand only to the diagnostic depth you need. Use Lanes for exact timing.".to_string(), - ), - icon: Some(&icondata::AiApiOutlined), - header_right: if show_context_controls { - Some(rsx! { - ManualRefreshStatus { refresh_tick } - RefreshButton { - onclick: move |_| refresh.set(refresh() + 1), - } - }) - } else { - None - }, - } - - Card { - title: "Span Tree", - content_class: Some("p-0"), - header_right: Some(rsx! { - TraceToolbar { - refresh, - filter, - expand_all, - collapse_all, - trace_id_filter, - thread_filter, - min_ms_filter, - active_only, - show_advanced, - clear_filters_tick, - summary_layout, - show_context_controls, - } - }), - AsyncBoundary { - message: Some("Loading trace data…".to_string()), - TraceTreePanel { - refresh, - filter, - trace_id_filter, - thread_filter, - min_ms_filter, - active_only, - expand_all, - collapse_all, - summary_layout, - } - } - } - } - } -} - -#[component] -fn TraceToolbar( - refresh: Signal, - filter: Signal, - expand_all: Signal, - collapse_all: Signal, - trace_id_filter: Signal, - thread_filter: Signal, - min_ms_filter: Signal, - active_only: Signal, - show_advanced: Signal, - clear_filters_tick: Signal, - summary_layout: Signal, - show_context_controls: bool, -) -> Element { - let limit = *SPANS_TREE_LIMIT.read(); - let filters_active = { - let _ = clear_filters_tick(); - !filter.read().trim().is_empty() - || !trace_id_filter.read().trim().is_empty() - || !thread_filter.read().trim().is_empty() - || !min_ms_filter.read().trim().is_empty() - || active_only() - || { - let ctx = INVESTIGATION_CONTEXT.read(); - ctx.tid.is_some() - || ctx.trace_id.is_some() - || ctx.span_name.is_some() - || ctx.local_step.is_some() - } - }; - rsx! { - div { class: "flex flex-col gap-2 max-w-3xl w-full", - div { class: "flex flex-wrap items-center gap-2", - div { class: "relative min-w-[140px] flex-1", - span { class: "absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none", - Icon { icon: &icondata::AiSearchOutlined, class: "w-3.5 h-3.5" } - } - input { - r#type: "text", - class: "w-full pl-7 pr-2 py-1.5 text-xs rounded-md border border-gray-300 bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500", - placeholder: "Filter spans…", - value: "{filter}", - oninput: move |ev| { - let value = ev.value(); - filter.set(value.clone()); - sync_spans_filters_to_context( - &value, - &thread_filter.read(), - &trace_id_filter.read(), - ); - }, - } - } - button { - class: "px-2 py-1.5 text-xs rounded-md border border-gray-300 bg-white hover:bg-gray-50", - title: "Expand all spans", - onclick: move |_| expand_all.set(expand_all() + 1), - "Expand" - } - button { - class: "px-2 py-1.5 text-xs rounded-md border border-gray-300 bg-white hover:bg-gray-50", - title: "Collapse all spans", - onclick: move |_| collapse_all.set(collapse_all() + 1), - "Collapse" - } - div { class: "flex items-center rounded-md border border-gray-300 bg-white p-0.5 text-[10px]", - button { - class: if summary_layout() { "rounded bg-blue-600 px-2 py-1 font-medium text-white" } else { "rounded px-2 py-1 text-gray-500 hover:bg-gray-50" }, - onclick: move |_| summary_layout.set(true), - "Tree" - } - button { - class: if summary_layout() { "rounded px-2 py-1 text-gray-500 hover:bg-gray-50" } else { "rounded bg-blue-600 px-2 py-1 font-medium text-white" }, - onclick: move |_| summary_layout.set(false), - "Lanes" - } - } - button { - class: if show_advanced() { - "px-2 py-1.5 text-xs rounded-md border border-blue-300 bg-blue-50 text-blue-700" - } else { - "px-2 py-1.5 text-xs rounded-md border border-gray-300 bg-white hover:bg-gray-50" - }, - onclick: move |_| show_advanced.set(!show_advanced()), - "Filters" - } - if filters_active { - button { - class: "px-2 py-1.5 text-xs rounded-md border border-gray-300 bg-white hover:bg-gray-50 text-gray-700", - title: "Clear all span filters", - onclick: move |_| { - filter.set(String::new()); - trace_id_filter.set(String::new()); - thread_filter.set(String::new()); - min_ms_filter.set(String::new()); - active_only.set(false); - show_advanced.set(false); - clear_spans_investigation_filters(); - clear_filters_tick.set(clear_filters_tick() + 1); - }, - "Clear filters" - } - } - Link { - to: Route::ProfilingViewPage { view: "trace".to_string() }, - class: format!( - "px-2 py-1.5 text-xs rounded-md border border-{} bg-{} text-{} hover:opacity-90 whitespace-nowrap", - colors::CONTENT_ACCENT_BORDER, - colors::CONTENT_ACCENT_BG, - colors::CONTENT_ACCENT_TEXT, - ), - title: "Open chrome trace event timeline under Profiling (not this span tree)", - "Chrome trace →" - } - if show_context_controls { - div { class: "flex items-center gap-2 pl-1 border-l border-gray-200", - span { class: "text-xs text-gray-500 whitespace-nowrap font-mono", "{limit} rows" } - input { - r#type: "range", - min: "{SPANS_LIMIT_MIN}", - max: "{SPANS_LIMIT_MAX}", - step: "{SPANS_LIMIT_STEP}", - value: "{limit}", - class: "w-24 accent-blue-600", - title: "Max trace_event rows loaded for the span tree", - oninput: move |ev| { - if let Ok(val) = ev.value().parse::() { - *SPANS_TREE_LIMIT.write() = val; - refresh.set(refresh() + 1); - } - }, - } - } - } - } - if show_advanced() { - div { class: "flex flex-wrap items-center gap-2", - input { - r#type: "text", - class: "w-28 px-2 py-1.5 text-xs rounded-md border border-gray-300 bg-white font-mono", - placeholder: "trace id", - value: "{trace_id_filter}", - oninput: move |ev| { - let value = ev.value(); - trace_id_filter.set(value.clone()); - sync_spans_filters_to_context( - &filter.read(), - &thread_filter.read(), - &value, - ); - }, - } - input { - r#type: "text", - class: "w-24 px-2 py-1.5 text-xs rounded-md border border-gray-300 bg-white font-mono", - placeholder: "thread", - value: "{thread_filter}", - oninput: move |ev| { - let value = ev.value(); - thread_filter.set(value.clone()); - sync_spans_filters_to_context( - &filter.read(), - &value, - &trace_id_filter.read(), - ); - }, - } - input { - r#type: "text", - class: "w-24 px-2 py-1.5 text-xs rounded-md border border-gray-300 bg-white font-mono", - placeholder: "min ms", - value: "{min_ms_filter}", - oninput: move |ev| min_ms_filter.set(ev.value()), - } - label { class: "inline-flex items-center gap-1.5 text-xs text-gray-600", - input { - r#type: "checkbox", - class: "rounded border-gray-300", - checked: active_only(), - onchange: move |ev| active_only.set(ev.checked()), - } - "Active only" - } - } - } - } - } -} - -#[component] -fn TraceTreePanel( - refresh: Signal, - filter: Signal, - trace_id_filter: Signal, - thread_filter: Signal, - min_ms_filter: Signal, - active_only: Signal, - expand_all: Signal, - collapse_all: Signal, - summary_layout: Signal, -) -> Element { - let spans = use_app_resource(move || { - let _ = refresh(); - let _ = *SPANS_TREE_RELOAD.read(); - let limit_val = *SPANS_TREE_LIMIT.read(); - async move { ApiClient::new().get_span_tree(Some(limit_val)).await } - }); - let tree = spans.suspend()?(); - - query_result( - tree, - |spans| spans.is_empty(), - "No trace data available. Start tracing with probing.tracing.span() or TorchProbe spans.", - move |spans| { - let ctx = INVESTIGATION_CONTEXT.read().clone(); - let highlight = SpanHighlight::from_context(&ctx); - let advanced = TraceAdvancedFilters { - trace_id: trace_id_filter().trim().parse().ok(), - thread_id: thread_filter().trim().parse().ok(), - min_duration_ms: min_ms_filter().trim().parse().ok(), - active_only: active_only(), - local_step: ctx.local_step, - }; - let mut filtered = filter_span_tree(&spans, &filter(), &advanced); - sort_span_tree(&mut filtered); - let total = count_spans(&spans); - let roots = spans.len(); - let shown = count_spans(&filtered); - let limit_display = *SPANS_TREE_LIMIT.read(); - let filter_summary = active_filter_summary(&filter(), &advanced); - rsx! { - div { class: "border-b border-gray-200 px-4 py-2 bg-gray-50/80 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-gray-600", - span { class: "font-medium text-gray-800", "{roots} roots" } - span { "·" } - span { "{total} spans" } - span { "·" } - span { "limit {limit_display}" } - if !ctx.is_empty() { - span { "·" } - span { - class: "text-blue-700", - title: "Filters sync with investigation context and URL", - "Context linked" - } - } - if !filter_summary.is_empty() { - span { "·" } - span { class: "text-blue-700", "{shown} matched · {filter_summary}" } - } - } - if filtered.is_empty() { - div { class: "px-4 py-10", - EmptyState { - message: if filter_summary.is_empty() { - "No spans in the current window.".to_string() - } else { - format!("No spans match {filter_summary}") - }, - } - } - } else { - { - let time_window = TraceTimeWindow::from_spans(&filtered); - rsx! { - div { class: "max-h-[calc(100vh-14rem)] overflow-y-auto font-mono text-xs leading-5", - if summary_layout() { - SpanSummaryHeader { window: time_window } - } else { - SpanTimelineHeader { window: time_window } - SpanTimelineLegend {} - } - div { class: "px-0 py-1", - for span in filtered { - SpanView { - key: "{span.span_id}", - span: span.clone(), - depth: 0, - highlight: highlight.clone(), - expand_all, - collapse_all, - time_window, - summary_layout: summary_layout(), - } - } - } - } - } - } - } - } - }, - ) -} - -fn count_spans(spans: &[SpanInfo]) -> usize { - spans.iter().map(|s| 1 + count_spans(&s.children)).sum() -} - -fn sort_span_tree(spans: &mut [SpanInfo]) { - spans.sort_by_key(|span| span.start_timestamp); - for span in spans { - sort_span_tree(&mut span.children); - span.events.sort_by_key(|event| event.timestamp); - } -} - -fn filter_span_tree( - spans: &[SpanInfo], - query: &str, - advanced: &TraceAdvancedFilters, -) -> Vec { - spans - .iter() - .filter_map(|span| { - let children = filter_span_tree(&span.children, query, advanced); - let self_matches = - span_matches_text(span, query) && span_matches_advanced(span, advanced); - if self_matches || !children.is_empty() { - Some(SpanInfo { - children, - ..span.clone() - }) - } else { - None - } - }) - .collect() -} - -fn span_matches_text(span: &SpanInfo, query: &str) -> bool { - let q = query.trim().to_lowercase(); - if q.is_empty() { - return true; - } - span.name.to_lowercase().contains(&q) - || span - .phase - .as_ref() - .is_some_and(|p| p.to_lowercase().contains(&q)) - || span - .location - .as_ref() - .is_some_and(|l| l.to_lowercase().contains(&q)) - || span - .attributes - .as_ref() - .is_some_and(|a| a.to_lowercase().contains(&q)) -} - -fn active_filter_summary(query: &str, advanced: &TraceAdvancedFilters) -> String { - let mut parts = Vec::new(); - let q = query.trim(); - if !q.is_empty() { - parts.push(format!("text:{q:?}")); - } - if let Some(trace_id) = advanced.trace_id { - parts.push(format!("trace_id={trace_id}")); - } - if let Some(thread_id) = advanced.thread_id { - parts.push(format!("thread={thread_id}")); - } - if let Some(step) = advanced.local_step { - parts.push(format!("local_step={step}")); - } - if let Some(min_ms) = advanced.min_duration_ms { - parts.push(format!("min_ms={min_ms}")); - } - if advanced.active_only { - parts.push("active".to_string()); - } - parts.join(", ") -} - -struct TraceAdvancedFilters { - trace_id: Option, - thread_id: Option, - min_duration_ms: Option, - active_only: bool, - local_step: Option, -} - -fn span_local_step(span: &SpanInfo) -> Option { - let raw = span.attributes.as_ref()?; - let value: serde_json::Value = serde_json::from_str(raw).ok()?; - value - .get("local_step") - .and_then(|v| v.as_i64().or_else(|| v.as_u64().map(|n| n as i64))) -} - -fn span_matches_advanced(span: &SpanInfo, filters: &TraceAdvancedFilters) -> bool { - if let Some(trace_id) = filters.trace_id { - if span.trace_id != trace_id { - return false; - } - } - if let Some(thread_id) = filters.thread_id { - if span.thread_id != thread_id { - return false; - } - } - if let Some(step) = filters.local_step { - match span_local_step(span) { - Some(s) if s == step => {} - _ => return false, - } - } - if filters.active_only && span.end_timestamp.is_some() { - return false; - } - if let Some(min_ms) = filters.min_duration_ms { - if let Some(end) = span.end_timestamp { - let dur_ms = (end - span.start_timestamp) as f64 / 1_000_000.0; - if dur_ms < min_ms { - return false; - } - } else { - return false; - } - } - true -} - -fn span_duration_secs(span: &SpanInfo) -> Option { - span.end_timestamp - .map(|end| (end - span.start_timestamp) as f64 / 1_000_000_000.0) -} - -fn duration_label(duration: f64) -> String { - if duration >= 1.0 { - format!("{duration:.3}s") - } else if duration >= 0.001 { - format!("{:.1}ms", duration * 1000.0) - } else { - format!("{:.0}us", duration * 1_000_000.0) - } -} - -fn span_is_primary_selection(span: &SpanInfo, highlight: &SpanHighlight) -> bool { - if let Some(trace_id) = highlight.trace_id { - if span.trace_id != trace_id { - return false; - } - return highlight - .span_name - .as_ref() - .map(|n| n == &span.name) - .unwrap_or(true); - } - false -} - -fn span_matches_thread_only(span: &SpanInfo, highlight: &SpanHighlight) -> bool { - highlight.trace_id.is_none() && highlight.tid == Some(span.thread_id as i32) -} - -fn span_row_class(span: &SpanInfo, highlight: &SpanHighlight) -> String { - let base = "group cursor-pointer"; - if span_is_primary_selection(span, highlight) { - format!("{base} bg-blue-100 ring-1 ring-inset ring-blue-300 hover:bg-blue-100") - } else if span_matches_thread_only(span, highlight) { - format!("{base} bg-blue-50/70 hover:bg-blue-50") - } else { - format!("{base} hover:bg-gray-50/90") - } -} - -#[derive(Clone, PartialEq)] -struct SpanHighlight { - trace_id: Option, - tid: Option, - span_name: Option, -} - -impl SpanHighlight { - fn from_context(ctx: &InvestigationContext) -> Self { - Self { - trace_id: ctx.trace_id, - tid: ctx.tid, - span_name: ctx.span_name.clone(), - } - } -} - -#[component] -fn SpanView( - span: SpanInfo, - depth: usize, - highlight: SpanHighlight, - expand_all: Signal, - collapse_all: Signal, - time_window: TraceTimeWindow, - summary_layout: bool, -) -> Element { - let mut expanded = use_signal(|| depth < 2); - let has_children = !span.children.is_empty(); - let has_events = !span.events.is_empty(); - let has_attrs = span - .attributes - .as_ref() - .is_some_and(|a| !a.trim().is_empty()); - let has_details = has_children || has_events; - let duration = span_duration_secs(&span); - let indent = depth * 20; - let trace_id = span.trace_id; - let thread_id = span.thread_id as i32; - let span_name = span.name.clone(); - let row_class = span_row_class(&span, &highlight); - let mut show_attributes = use_signal(|| false); - let total_ns = span_total_ns(&span, time_window); - let self_ns = span_self_ns(&span); - let cover = span_cover_pct(&span, time_window); - let subtree_count = count_spans(&span.children); - - use_effect(move || { - if expand_all() > 0 { - expanded.set(true); - } - }); - use_effect(move || { - if collapse_all() > 0 { - expanded.set(false); - } - }); - - rsx! { - div { class: "min-w-0", - if summary_layout { - div { - class: "{row_class} flex min-w-[960px] items-center border-b border-gray-100 hover:bg-gray-50/80", - div { - class: "flex w-[38%] min-w-[320px] shrink-0 items-center gap-2 py-1.5 pr-3", - style: format!("padding-left: {}px", indent + 12), - onclick: move |_| { - set_trace_context(trace_id, Some(&span_name), Some(thread_id)); - }, - if has_details { - button { - class: "shrink-0 w-4 h-4 flex items-center justify-center text-gray-400 hover:text-gray-700", - onclick: move |e| { - e.stop_propagation(); - expanded.set(!expanded()); - }, - if expanded() { - Icon { icon: &icondata::AiCaretDownOutlined, class: "w-3 h-3" } - } else { - Icon { icon: &icondata::AiCaretRightOutlined, class: "w-3 h-3" } - } - } - } else { - span { class: "w-4 shrink-0" } - } - div { class: "min-w-0 flex-1", - div { class: "flex min-w-0 items-center gap-2", - span { class: "truncate font-sans text-xs font-semibold text-gray-900", "{span.name}" } - if let Some(ref phase) = span.phase { - span { - class: format!( - "shrink-0 rounded px-1.5 py-px font-sans text-[10px] font-medium bg-{} text-{}", - colors::CONTENT_ACCENT_BG, - colors::CONTENT_ACCENT_TEXT, - ), - "{phase}" - } - } - if span.end_timestamp.is_none() { - span { class: "shrink-0 font-sans text-[10px] font-medium text-amber-600", "active" } - } - } - div { class: "flex min-w-0 items-center gap-2 font-mono text-[9px] text-gray-400", - span { class: "shrink-0", "id {span.span_id}" } - span { class: "shrink-0", "thread {span.thread_id}" } - if subtree_count > 0 { - span { class: "shrink-0", "{subtree_count} nested" } - } - if has_events { - span { class: "shrink-0", "{span.events.len()} events" } - } - if let Some(ref location) = span.location { - if !location.is_empty() { - span { class: "truncate", "{location}" } - } - } - } - } - if has_attrs { - button { - class: if show_attributes() { - "shrink-0 rounded border border-blue-200 bg-blue-50 px-1.5 font-sans text-[9px] text-blue-700" - } else { - "shrink-0 rounded border border-gray-200 px-1.5 font-sans text-[9px] text-gray-400 opacity-0 transition-opacity group-hover:opacity-100" - }, - title: "Show span attributes", - onclick: move |event| { - event.stop_propagation(); - show_attributes.set(!show_attributes()); - }, - "meta" - } - } - } - SpanSummaryBar { span: span.clone(), window: time_window } - div { class: "w-24 shrink-0 px-3 text-right font-mono text-[10px] font-medium tabular-nums text-gray-700", - if span.end_timestamp.is_some() { "{format_axis_duration(total_ns)}" } else { "active" } - } - div { class: "w-24 shrink-0 px-3 text-right font-mono text-[10px] font-medium tabular-nums text-blue-700", - if let Some(value) = self_ns { "{format_axis_duration(value)}" } else { "—" } - } - div { class: "w-16 shrink-0 px-3 text-right font-mono text-[10px] tabular-nums text-gray-500", "{cover:.1}%" } - } - } else { - div { class: "flex min-w-0 items-stretch hover:bg-gray-50/50", - div { class: "flex min-w-0 flex-1 items-stretch", - SpanTimelineBar { span: span.clone(), window: time_window, depth } - div { - class: "{row_class} flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-0.5 border-b border-gray-50 px-1 py-0.5", - style: if indent > 0 { format!("padding-left: {indent}px") } else { String::new() }, - onclick: move |_| set_trace_context(trace_id, Some(&span_name), Some(thread_id)), - if has_details { - button { - class: "flex h-4 w-4 shrink-0 items-center justify-center text-gray-400 hover:text-gray-700", - onclick: move |event| { - event.stop_propagation(); - expanded.set(!expanded()); - }, - if expanded() { - Icon { icon: &icondata::AiCaretDownOutlined, class: "h-3 w-3" } - } else { - Icon { icon: &icondata::AiCaretRightOutlined, class: "h-3 w-3" } - } - } - } else { - span { class: "w-4 shrink-0" } - } - span { class: "shrink-0 font-semibold text-gray-900", "{span.name}" } - if let Some(ref phase) = span.phase { - span { class: format!("shrink-0 rounded px-1.5 py-px font-sans text-[10px] font-medium bg-{} text-{}", colors::CONTENT_ACCENT_BG, colors::CONTENT_ACCENT_TEXT), "{phase}" } - } - span { class: "shrink-0 text-gray-400", "thread {span.thread_id}" } - if let Some(dur) = duration { - span { class: "shrink-0 font-medium text-emerald-700", "{duration_label(dur)}" } - } else { - span { class: "shrink-0 text-amber-600", "active" } - } - } - } - } - } - - if show_attributes() && has_attrs { - div { class: "flex min-w-[960px] border-b border-gray-100 bg-blue-50/30", - if summary_layout { SpanSummarySpacer {} } else { SpanTimelineSpacer {} } - div { class: "min-w-0 flex-1 px-3 py-1.5", AttributesInline { raw: span.attributes.clone().unwrap_or_default() } } - } - } - - if expanded() && has_details { - if has_events { - for event in span.events.iter() { - div { class: "flex min-w-[960px] items-stretch border-b border-gray-50", - if summary_layout { SpanSummarySpacer {} } else { SpanTimelineSpacer {} } - div { - class: "min-w-0 flex-1", - style: format!("padding-left: {}px", indent + 20), - EventView { event: event.clone(), time_window, span_start: span.start_timestamp } - } - } - } - } - if has_children { - for child in span.children.iter() { - SpanView { - key: "{child.span_id}", - span: child.clone(), - depth: depth + 1, - highlight: highlight.clone(), - expand_all, - collapse_all, - time_window, - summary_layout, - } - } - } - } - } - } -} - -fn format_axis_duration(duration_ns: i64) -> String { - crate::components::span_timeline::format_axis_label(duration_ns.max(0) as f64) -} - -#[component] -fn AttributesInline(raw: String) -> Element { - rsx! { - if let Ok(parsed) = serde_json::from_str::(&raw) { - if let Some(obj) = parsed.as_object() { - div { class: "flex flex-wrap items-center gap-x-3 gap-y-0.5 py-0.5 text-gray-600", - for (key, val) in obj.iter() { - span { class: "inline-flex items-baseline gap-1 max-w-full", - span { class: "text-gray-500 shrink-0", "{key}:" } - span { class: "text-gray-800 break-all", { attribute_value(val) } } - } - } - } - } else { - MetaInline { text: raw } - } - } else { - MetaInline { text: raw } - } - } -} - -#[component] -fn MetaInline(text: String) -> Element { - rsx! { - div { class: "py-0.5 text-gray-600 break-all whitespace-pre-wrap", "{text}" } - } -} - -fn attribute_value(val: &serde_json::Value) -> String { - match val { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Null => "null".to_string(), - _ => val.to_string(), - } -} - -#[component] -fn EventView(event: EventInfo, time_window: TraceTimeWindow, span_start: i64) -> Element { - let offset = crate::components::span_timeline::format_axis_label( - (event.timestamp - time_window.start_ns) as f64, - ); - let rel = - crate::components::span_timeline::format_axis_label((event.timestamp - span_start) as f64); - rsx! { - div { - class: "flex flex-wrap items-baseline gap-x-2 gap-y-0 py-0.5 text-gray-600", - title: "t+{rel} from span start · {offset} from trace origin", - span { class: "text-blue-500 shrink-0", "●" } - span { class: "text-gray-800", "{event.name}" } - span { class: "text-gray-400 shrink-0 font-mono text-[10px]", "+{rel}" } - if let Some(ref attrs) = event.attributes { - if !attrs.is_empty() { - span { class: "text-gray-500 break-all", "{attrs}" } - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn span(id: i64, start: i64, children: Vec) -> SpanInfo { - SpanInfo { - span_id: id, - trace_id: 1, - parent_id: None, - name: format!("span-{id}"), - start_timestamp: start, - end_timestamp: Some(start + 10), - thread_id: 1, - phase: None, - location: None, - attributes: None, - children, - events: vec![], - } - } - - #[test] - fn summary_tree_sorts_every_hierarchy_level_by_time() { - let mut spans = vec![ - span(2, 20, vec![]), - span(1, 10, vec![span(4, 18, vec![]), span(3, 12, vec![])]), - ]; - - sort_span_tree(&mut spans); - - assert_eq!( - spans.iter().map(|span| span.span_id).collect::>(), - vec![1, 2] - ); - assert_eq!( - spans[0] - .children - .iter() - .map(|span| span.span_id) - .collect::>(), - vec![3, 4] - ); - } -} diff --git a/web/src/pages/training.rs b/web/src/pages/training.rs deleted file mode 100644 index f603a85f..00000000 --- a/web/src/pages/training.rs +++ /dev/null @@ -1,1894 +0,0 @@ -//! Training observability: local step/collective views + on-demand cluster scan. - -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; - -use dioxus::prelude::*; -use probing_proto::prelude::Node; - -use crate::agent::load_skill; -use crate::api::{ApiClient, ClusterQueryResponse, StepDurationSample, StepMatrixResponse}; -use crate::components::card::Card; -use crate::components::collapsible_card::CollapsibleCardWithIcon; -use crate::components::common::{AppErrorDisplay, AsyncBoundary, EmptyState, LoadingState}; -use crate::components::dataframe_view::DataFrameView; -use crate::components::icon::Icon; -use crate::components::page::{PageContainer, PageTitle}; -use crate::components::poll_status::{PollStatusBar, RefreshButton}; -use crate::components::workspace::{ChipButton, WidthSegment}; -use crate::hooks::{use_app_resource, use_page_visible, use_poll_tick_gated}; -use crate::state::agent::{AGENT_INPUT, AGENT_PANEL_OPEN}; -use crate::state::investigation::{apply_context_from_dataframe_row, set_training_step_context}; -use crate::state::training::{ - placement_availability, TRAINING_CLUSTER_SCOPE, TRAINING_PLACEMENT_AVAILABILITY, - TRAINING_REFRESH, -}; -use crate::state::ui_tasks::ui_agent_busy; -use crate::utils::error::AppError; - -const POLL_MS: u32 = 5000; -const STEP_LIMIT: usize = 120; -const COMM_LIMIT: usize = 30; -const STEP_CARD_TITLE: &str = "Step time"; - -const COMM_SQL: &str = "SELECT local_step, rank, op, group_size, duration_ms, bytes, role \ - FROM python.comm_collective ORDER BY timestamp DESC LIMIT "; - -const COMM_SUMMARY_SQL: &str = "SELECT op, count(*) AS n, \ - round(avg(duration_ms), 2) AS avg_ms, round(max(duration_ms), 2) AS max_ms, \ - sum(bytes) AS total_bytes \ - FROM python.comm_collective GROUP BY op ORDER BY avg_ms DESC LIMIT 10"; - -const MODULE_HOTSPOTS_SQL: &str = "SELECT module, stage, count(DISTINCT local_step) AS steps, \ - count(*) AS hooks, round(avg(duration), 4) AS avg_sec, round(sum(duration), 4) AS total_sec \ - FROM python.torch_trace \ - WHERE local_step >= GREATEST(COALESCE((SELECT max(local_step) FROM python.torch_trace), 0) - 9, 1) \ - AND stage LIKE 'post %' AND duration > 0 \ - AND module IS NOT NULL AND module != '' AND module != 'None' \ - GROUP BY module, stage ORDER BY total_sec DESC LIMIT 12"; - -const STEP_PHASE_SQL: &str = "SELECT local_step, \ - round(sum(CASE WHEN stage = 'post forward' THEN duration ELSE 0 END), 4) AS forward_sec, \ - round(sum(CASE WHEN stage = 'post step' THEN duration ELSE 0 END), 4) AS optim_sec \ - FROM python.torch_trace \ - WHERE local_step >= GREATEST(COALESCE((SELECT max(local_step) FROM python.torch_trace), 0) - 15, 1) \ - AND stage LIKE 'post %' AND duration > 0 \ - AND module IS NOT NULL AND module != '' AND module != 'None' \ - GROUP BY local_step ORDER BY local_step"; - -const QUICK_SKILLS: &[(&str, &str)] = &[ - ("slow_rank", "Slow rank"), - ("nccl_culprit_victim", "NCCL"), - ("comm_bottleneck", "Comm"), - ("module_bottleneck", "Bottleneck"), -]; - -#[derive(Clone, Copy, PartialEq, Eq)] -enum DataScope { - Local, - Cluster, -} - -#[derive(Clone, Debug)] -struct ClusterScanOutput { - matrix: Result, - comm: Result, - nodes_failed: Vec, -} - -#[derive(Clone, Debug, PartialEq)] -struct HeatCell { - duration_ms: f64, - outlier: bool, -} - -/// A training step selected in the step chart (display index + trace coordinate). -#[derive(Clone, Debug, PartialEq)] -struct SelectedStep { - rank: i32, - display_step: i64, - coord_step: i64, - duration_ms: f64, -} - -#[derive(Clone, Debug, PartialEq)] -struct StepPoint { - display_step: i64, - coord_step: i64, - duration_ms: f64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct PlacementProcess { - rank: Option, - local_rank: Option, - role_label: Option, - coordinates: Vec<(String, String)>, - status: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct PlacementHost { - name: String, - processes: Vec, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -struct PlacementModel { - hosts: Vec, - observed_ranks: usize, - expected_ranks: usize, - has_parallel_coordinates: bool, - parallel_sizes: Vec<(String, usize)>, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PlacementGroup { - Focus, - Tensor, - Data, - Pipeline, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct PlacementGroupSizes { - tensor: usize, - data: usize, - pipeline: usize, -} - -fn trace_step(coord: i64, display: i64) -> i64 { - if coord >= 0 { - coord - } else { - display - } -} - -fn step_module_sql(coord_step: i64) -> String { - format!( - "SELECT module, stage, round(duration, 4) AS sec \ - FROM python.torch_trace \ - WHERE local_step = {coord_step} AND stage LIKE 'post %' AND duration > 0 \ - AND module IS NOT NULL AND module != '' AND module != 'None' \ - ORDER BY duration DESC LIMIT 12" - ) -} - -fn step_span_sql(display_step: i64) -> String { - format!( - "SELECT s.name, s.phase, round((CAST(e.time AS BIGINT) - CAST(s.time AS BIGINT)) / 1000000.0, 2) AS duration_ms \ - FROM python.trace_event s \ - JOIN python.trace_event e ON s.span_id = e.span_id AND e.record_type = 'span_end' \ - WHERE s.record_type = 'span_start' AND s.name != 'train.step' \ - AND s.attributes LIKE '%\"local_step\":{display_step}%' \ - ORDER BY duration_ms DESC LIMIT 12" - ) -} - -fn select_training_step( - rank: i32, - display_step: i64, - coord_step: i64, - duration_ms: f64, - mut selected: Signal>, -) { - selected.set(Some(SelectedStep { - rank, - display_step, - coord_step, - duration_ms, - })); - set_training_step_context(rank, Some(display_step), None); -} - -#[component] -pub fn Training(#[props(default = true)] show_controls: bool) -> Element { - let visible = use_page_visible(); - let poll = use_poll_tick_gated(POLL_MS, Some(visible)); - let local_tick = poll().wrapping_add(*TRAINING_REFRESH.read()); - - let nodes = use_app_resource(|| { - let _ = *TRAINING_REFRESH.read(); - async move { ApiClient::new().get_nodes().await } - }); - use_effect(move || { - *TRAINING_PLACEMENT_AVAILABILITY.write() = placement_availability(nodes().as_ref()); - }); - let mut cluster_scan = use_action(|| async move { - let client = ApiClient::new(); - let matrix_res = client.fetch_step_matrix(STEP_LIMIT, true).await; - let comm_res = client - .cluster_query(&format!("{COMM_SQL}{COMM_LIMIT}"), true) - .await; - - let mut failed: HashSet = HashSet::new(); - if let Ok(ref m) = matrix_res { - failed.extend(m.nodes_failed.iter().cloned()); - } - if let Ok(ref c) = comm_res { - failed.extend(c.meta.nodes_failed.iter().cloned()); - } - let mut merged: Vec = failed.into_iter().collect(); - merged.sort(); - - Ok::(ClusterScanOutput { - matrix: matrix_res, - comm: comm_res, - nodes_failed: merged, - }) - }); - - use_effect(move || { - let refresh = *TRAINING_REFRESH.read(); - if *TRAINING_CLUSTER_SCOPE.read() { - let _ = refresh; - cluster_scan.call(); - } - }); - - let node_state = nodes(); - let peer_count = node_state - .as_ref() - .and_then(|r| r.as_ref().ok()) - .map(|nodes| nodes.len().saturating_sub(1)) - .unwrap_or(0); - - let current_scope = if *TRAINING_CLUSTER_SCOPE.read() { - DataScope::Cluster - } else { - DataScope::Local - }; - let scan_pending = cluster_scan.pending(); - let selected_step = use_signal(|| None::); - - rsx! { - PageContainer { - PageTitle { - title: "Training".to_string(), - subtitle: None, - icon: Some(&icondata::AiRadarChartOutlined), - header_right: if show_controls { - Some(rsx! { - if current_scope == DataScope::Local { - PollStatusBar { - interval_secs: POLL_MS / 1000, - poll_tick: local_tick, - } - } - RefreshButton { - onclick: move |_| *TRAINING_REFRESH.write() += 1, - } - }) - } else { - None - }, - } - - if show_controls { - TrainingScopeBar { - scope: current_scope, - peer_count, - scan_pending, - on_local: move |_| *TRAINING_CLUSTER_SCOPE.write() = false, - on_cluster_scan: move |_| { - *TRAINING_CLUSTER_SCOPE.write() = true; - *TRAINING_REFRESH.write() += 1; - }, - } - } - - if current_scope == DataScope::Cluster { - if let Some(Ok(output)) = cluster_scan.value() { - {cluster_nodes_failed_banner(&output().nodes_failed)} - } - } - - div { class: "space-y-4", - div { class: "min-w-0", - if current_scope == DataScope::Local { - AsyncBoundary { - message: Some("Loading step timings…".to_string()), - LocalStepMatrixPanel { - refresh_tick: local_tick, - selected_step, - } - } - } else if scan_pending { - Card { - title: STEP_CARD_TITLE, - LoadingState { message: Some("Scanning cluster…".to_string()) } - } - } else if let Some(Err(err)) = cluster_scan.value() { - Card { - title: STEP_CARD_TITLE, - AppErrorDisplay { - error: AppError::Api(err.to_string()), - title: Some("Cluster scan failed".to_string()), - } - } - } else if let Some(Ok(output)) = cluster_scan.value() { - ClusterStepMatrixPanel { - matrix: output().matrix.clone(), - selected_step, - } - } else { - Card { - title: STEP_CARD_TITLE, - EmptyState { message: "Run a cluster scan to compare ranks.".to_string() } - } - } - } - TrainingPlacement { node_state: node_state.clone() } - } - - StepInspectorOverlay { selected: selected_step } - - div { class: "mt-4 space-y-3", - if current_scope == DataScope::Local { - AsyncBoundary { - message: Some("Loading module data…".to_string()), - LocalModuleHotspotsPanel { refresh_tick: local_tick } - } - AsyncBoundary { - message: Some("Loading collective data…".to_string()), - LocalCommPanel { refresh_tick: local_tick } - } - } else if let Some(Ok(output)) = cluster_scan.value() { - {render_comm_cluster_collapsible(&output().comm)} - } else if scan_pending { - CollapsibleCommPlaceholder {} - } - } - } - } -} - -#[component] -fn TrainingPlacement(node_state: Option, AppError>>) -> Element { - match node_state { - Some(Ok(nodes)) if !nodes.is_empty() => rsx! { - Card { - title: "Placement", - content_class: Some("p-4"), - PlacementDiagram { placement: build_placement(&nodes) } - } - }, - _ => rsx! {}, - } -} - -#[component] -fn PlacementDiagram(placement: PlacementModel) -> Element { - let missing_ranks = placement - .expected_ranks - .saturating_sub(placement.observed_ranks); - rsx! { - div { class: "space-y-3", - div { class: "flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-gray-600", - span { class: "font-medium text-gray-900", "{placement.hosts.len()} hosts" } - span { "{placement.observed_ranks} / {placement.expected_ranks} ranks" } - if placement.has_parallel_coordinates { - for (dimension, size) in placement.parallel_sizes.iter() { - span { class: "font-mono font-semibold uppercase text-violet-700", - "{dimension}{size}" - } - } - } else if placement.expected_ranks > 1 { - span { class: "text-gray-400", "parallel roles unavailable" } - } - if missing_ranks > 0 { - span { class: "rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-800", - "{missing_ranks} missing" - } - } - } - - PlacementOverview { placement: placement.clone() } - } - } -} - -#[component] -fn PlacementOverview(placement: PlacementModel) -> Element { - let mut hovered_rank = use_signal(|| None::); - let active_process = hovered_rank().and_then(|rank| { - placement - .hosts - .iter() - .flat_map(|host| host.processes.iter()) - .find(|process| process.rank == Some(rank)) - .cloned() - }); - let group_sizes = active_process - .as_ref() - .and_then(|active| placement_group_sizes(&placement, active)); - let host_columns = placement_host_columns(placement.hosts.len()); - - rsx! { - div { - class: "rounded-md border border-gray-200 bg-gray-50 px-3 py-2.5", - onmouseleave: move |_| hovered_rank.set(None), - div { class: "mb-2 flex flex-wrap items-center justify-between gap-2", - div { class: "flex items-center gap-2", - span { class: "text-[10px] font-medium uppercase tracking-wide text-gray-500", "Overview" } - if let Some(rank) = hovered_rank() { - span { class: "font-mono text-[10px] font-semibold text-blue-700", "R{rank}" } - } - } - div { class: "flex items-center gap-3 text-[9px] text-gray-500", - PlacementGroupLegend { - label: "TP", - count: group_sizes.map(|sizes| sizes.tensor), - class: "border-violet-500 bg-violet-100", - } - PlacementGroupLegend { - label: "DP", - count: group_sizes.map(|sizes| sizes.data), - class: "border-emerald-500 bg-emerald-100", - } - PlacementGroupLegend { - label: "PP", - count: group_sizes.map(|sizes| sizes.pipeline), - class: "border-amber-500 bg-amber-100", - } - span { class: "text-gray-400", "hover a GPU" } - } - } - div { class: "overflow-x-auto pb-0.5", - div { - class: "grid min-w-max gap-2", - style: "grid-template-columns: repeat({host_columns}, 26px);", - for (host_index, host) in placement.hosts.iter().enumerate() { - div { - class: "rounded border border-gray-200 bg-white p-1", - title: "{host.name}", - div { class: "mb-1 truncate text-center font-mono text-[8px] text-gray-400", "H{host_index}" } - div { class: "grid grid-cols-1 justify-items-center gap-0.5", - for process in host.processes.iter() { - { - let rank = process.rank; - let rank_label = rank.map(|value| format!("R{value}")).unwrap_or_else(|| "R?".to_string()); - let local_rank = process.local_rank.map(|value| value.to_string()).unwrap_or_else(|| "?".to_string()); - let gpu_label = format!("GPU{local_rank}"); - let status = process.status.as_deref().unwrap_or("unknown"); - let role = process.role_label.as_deref().unwrap_or("rank"); - let coordinates = process.coordinates.iter() - .map(|(dimension, value)| format!("{}{}", dimension.chars().next().unwrap_or('?').to_ascii_uppercase(), value)) - .collect::>() - .join(" "); - let group = active_process - .as_ref() - .and_then(|active| placement_group_membership(active, process)); - let cell_class = placement_overview_cell_class(group, process.status.as_deref()); - let cell_title = if coordinates.is_empty() { - format!("{rank_label} · {} · {gpu_label} · {status} · {role}", host.name) - } else { - format!("{rank_label} · {} · {gpu_label} · {status} · {role} · {coordinates}", host.name) - }; - rsx! { - button { - r#type: "button", - class: "flex h-4 w-4 items-center justify-center rounded-[2px] border text-[7px] font-mono transition-colors {cell_class}", - aria_label: "{cell_title}", - title: "{cell_title}", - onmouseover: move |_| hovered_rank.set(rank), - onfocus: move |_| hovered_rank.set(rank), - onclick: move |_| hovered_rank.set(rank), - onblur: move |_| hovered_rank.set(None), - "{local_rank}" - } - } - } - } - } - } - } - } - } - } - } -} - -#[component] -fn PlacementGroupLegend(label: &'static str, count: Option, class: &'static str) -> Element { - rsx! { - span { class: "flex items-center gap-1", - span { class: "h-3 w-3 rounded-[2px] border border-dashed {class}" } - "{label}" - if let Some(count) = count { - span { class: "font-mono font-semibold text-gray-700", "{count}" } - } - } - } -} - -fn placement_coordinate<'a>(process: &'a PlacementProcess, dimension: &str) -> Option<&'a str> { - process - .coordinates - .iter() - .find_map(|(key, value)| (key == dimension).then_some(value.as_str())) -} - -fn placement_group_membership( - active: &PlacementProcess, - candidate: &PlacementProcess, -) -> Option { - if active.rank == candidate.rank { - return Some(PlacementGroup::Focus); - } - - let active_dp = placement_coordinate(active, "dp")?; - let active_pp = placement_coordinate(active, "pp")?; - let active_tp = placement_coordinate(active, "tp")?; - let candidate_dp = placement_coordinate(candidate, "dp")?; - let candidate_pp = placement_coordinate(candidate, "pp")?; - let candidate_tp = placement_coordinate(candidate, "tp")?; - - if active_dp == candidate_dp && active_pp == candidate_pp { - Some(PlacementGroup::Tensor) - } else if active_pp == candidate_pp && active_tp == candidate_tp { - Some(PlacementGroup::Data) - } else if active_dp == candidate_dp && active_tp == candidate_tp { - Some(PlacementGroup::Pipeline) - } else { - None - } -} - -fn placement_group_sizes( - placement: &PlacementModel, - active: &PlacementProcess, -) -> Option { - placement_coordinate(active, "dp")?; - placement_coordinate(active, "pp")?; - placement_coordinate(active, "tp")?; - - let mut sizes = PlacementGroupSizes { - tensor: 1, - data: 1, - pipeline: 1, - }; - for candidate in placement - .hosts - .iter() - .flat_map(|host| host.processes.iter()) - { - match placement_group_membership(active, candidate) { - Some(PlacementGroup::Tensor) => sizes.tensor += 1, - Some(PlacementGroup::Data) => sizes.data += 1, - Some(PlacementGroup::Pipeline) => sizes.pipeline += 1, - Some(PlacementGroup::Focus) | None => {} - } - } - Some(sizes) -} - -fn placement_overview_cell_class( - group: Option, - status: Option<&str>, -) -> &'static str { - match group { - Some(PlacementGroup::Focus) => { - "border-blue-700 bg-blue-600 text-white ring-2 ring-blue-200" - } - Some(PlacementGroup::Tensor) => { - "border-dashed border-violet-500 bg-violet-100 text-violet-900" - } - Some(PlacementGroup::Data) => { - "border-dashed border-emerald-500 bg-emerald-100 text-emerald-900" - } - Some(PlacementGroup::Pipeline) => { - "border-dashed border-amber-500 bg-amber-100 text-amber-900" - } - None if matches!( - status.unwrap_or_default().to_ascii_lowercase().as_str(), - "failed" | "error" | "offline" | "unhealthy" - ) => - { - "border-red-400 bg-red-100 text-red-800" - } - None => "border-gray-300 bg-gray-100 text-gray-500 hover:border-blue-400 hover:bg-blue-50", - } -} - -fn placement_host_columns(host_count: usize) -> usize { - host_count.clamp(1, 8) -} - -fn parse_parallel_coordinates(role: Option<&str>) -> Vec<(String, String)> { - let mut parsed = BTreeMap::new(); - for part in role.unwrap_or_default().split(',') { - let Some((key, value)) = part.split_once('=') else { - continue; - }; - let key = key.trim().to_ascii_lowercase(); - let value = value.trim(); - if matches!(key.as_str(), "dp" | "pp" | "tp" | "sp" | "cp" | "ep") && !value.is_empty() { - parsed.insert(key, value.to_string()); - } - } - - ["dp", "pp", "tp", "sp", "cp", "ep"] - .into_iter() - .filter_map(|key| parsed.remove(key).map(|value| (key.to_string(), value))) - .collect() -} - -fn build_placement(nodes: &[Node]) -> PlacementModel { - let mut hosts: BTreeMap> = BTreeMap::new(); - let mut ranks = BTreeSet::new(); - let mut expected_ranks = 0usize; - - for node in nodes { - if let Some(rank) = node.rank { - ranks.insert(rank); - } - if let Some(world_size) = node.world_size.filter(|size| *size > 0) { - expected_ranks = expected_ranks.max(world_size as usize); - } - let coordinates = parse_parallel_coordinates(node.role.as_deref()); - let role_label = node - .role_name - .clone() - .filter(|role| !role.trim().is_empty()) - .or_else(|| coordinates.is_empty().then(|| node.role.clone()).flatten()); - let host = if node.host.trim().is_empty() { - "Unknown host".to_string() - } else { - node.host.clone() - }; - hosts.entry(host).or_default().push(PlacementProcess { - rank: node.rank, - local_rank: node.local_rank, - role_label, - coordinates, - status: node.status.clone(), - }); - } - - let mut hosts = hosts - .into_iter() - .map(|(name, mut processes)| { - processes.sort_by_key(|process| { - ( - process.rank.unwrap_or(i32::MAX), - process.local_rank.unwrap_or(i32::MAX), - ) - }); - PlacementHost { name, processes } - }) - .collect::>(); - hosts.sort_by(|a, b| a.name.cmp(&b.name)); - - let observed_ranks = ranks.len(); - if expected_ranks == 0 { - expected_ranks = observed_ranks; - } - let mut coordinate_values: BTreeMap> = BTreeMap::new(); - for process in hosts.iter().flat_map(|host| host.processes.iter()) { - for (dimension, value) in &process.coordinates { - coordinate_values - .entry(dimension.clone()) - .or_default() - .insert(value.clone()); - } - } - let parallel_sizes = ["dp", "pp", "tp", "sp", "cp", "ep"] - .into_iter() - .filter_map(|dimension| { - coordinate_values - .get(dimension) - .map(|values| (dimension.to_string(), values.len())) - }) - .collect::>(); - let has_parallel_coordinates = hosts - .iter() - .flat_map(|host| host.processes.iter()) - .any(|process| !process.coordinates.is_empty()); - - PlacementModel { - hosts, - observed_ranks, - expected_ranks, - has_parallel_coordinates, - parallel_sizes, - } -} - -fn queue_investigate_skill(skill_id: String) { - if load_skill(&skill_id).is_none() { - return; - } - *AGENT_PANEL_OPEN.write() = true; - *AGENT_INPUT.write() = format!("/{skill_id}"); -} - -#[component] -fn TrainingScopeBar( - scope: DataScope, - peer_count: usize, - scan_pending: bool, - on_local: EventHandler<()>, - on_cluster_scan: EventHandler<()>, -) -> Element { - let refresh_secs = POLL_MS / 1000; - let cluster_title: &'static str = if scan_pending { - "Scan in progress…" - } else { - "One-shot fan-out across training nodes" - }; - let status = if scope == DataScope::Local { - format!("Live · refreshes every {refresh_secs}s") - } else if peer_count == 0 { - "No peer nodes detected".to_string() - } else { - format!("On-demand scan · {peer_count} peer(s)") - }; - rsx! { - div { class: "flex flex-wrap items-center justify-between gap-3 mb-4", - div { class: "inline-flex items-center gap-0.5 p-0.5 rounded-lg bg-gray-100 border border-gray-200", - WidthSegment { - label: "This node", - selected: scope == DataScope::Local, - title: "Auto-refresh local train.step spans", - onclick: move |_| on_local.call(()), - } - WidthSegment { - label: "Cluster", - selected: scope == DataScope::Cluster, - title: cluster_title, - onclick: move |_| on_cluster_scan.call(()), - } - } - p { class: "text-xs text-gray-500", "{status}" } - } - } -} - -#[component] -fn StepInspectorOverlay(selected: Signal>) -> Element { - let Some(sel) = selected() else { - return rsx! {}; - }; - - rsx! { - div { - class: "fixed inset-0 z-40 flex justify-end pointer-events-none", - div { - class: "absolute inset-0 bg-black/20 pointer-events-auto", - onclick: move |_| selected.set(None), - } - div { - class: "relative h-full w-full max-w-md flex flex-col pointer-events-auto \ - bg-white shadow-2xl border-l border-gray-200", - role: "dialog", - aria_label: "Step inspector", - onclick: move |e| e.stop_propagation(), - div { - class: "shrink-0 px-4 py-3 border-b border-gray-100 bg-gradient-to-r from-violet-50/80 to-white \ - flex items-center justify-between gap-3", - div { class: "flex items-center gap-2 min-w-0", - Icon { icon: &icondata::AiSearchOutlined, class: "w-4 h-4 text-violet-600 shrink-0" } - div { class: "min-w-0", - div { class: "text-sm font-semibold text-gray-900", "Step inspector" } - div { class: "text-[10px] text-gray-500 truncate", - "Step {sel.display_step} · rank {sel.rank}" - } - } - } - button { - class: "shrink-0 p-1.5 rounded-md text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors", - title: "Close", - aria_label: "Close step inspector", - onclick: move |_| selected.set(None), - Icon { icon: &icondata::AiCloseOutlined, class: "w-4 h-4" } - } - } - div { class: "flex-1 min-h-0 overflow-y-auto p-4 space-y-3", - StepDetailLoaded { sel } - } - } - } - } -} - -#[component] -fn StepDetailLoaded(sel: SelectedStep) -> Element { - let display_step = sel.display_step; - let coord_step = sel.coord_step; - let spans = use_app_resource(move || { - let d = display_step; - async move { ApiClient::new().execute_query(&step_span_sql(d)).await } - }); - let modules = use_app_resource(move || { - let c = coord_step; - async move { ApiClient::new().execute_query(&step_module_sql(c)).await } - }); - - let spans_res = spans.suspend()?(); - let modules_res = modules.suspend()?(); - let avg_hint = format_step_ms(sel.duration_ms); - - rsx! { - div { class: "rounded-lg border border-violet-100 bg-violet-50/50 px-3 py-2", - div { class: "flex items-baseline justify-between gap-2", - span { class: "text-xs text-gray-500", "Step" } - span { class: "text-lg font-semibold font-mono text-gray-900", "{sel.display_step}" } - } - div { class: "mt-1 text-2xl font-semibold font-mono text-violet-800", "{avg_hint}" } - div { class: "mt-1 text-[10px] font-mono text-gray-500", - "rank {sel.rank} · trace step {sel.coord_step}" - } - } - div { class: "flex flex-wrap gap-1.5", - for (id, label) in QUICK_SKILLS { - ChipButton { - label: (*label).to_string(), - disabled: ui_agent_busy(), - onclick: { - let skill_id = (*id).to_string(); - move |_| queue_investigate_skill(skill_id.clone()) - }, - } - } - } - p { class: "text-[10px] text-gray-400", "Opens Investigate with skill · context pinned" } - StepDetailSection { - title: "Span breakdown", - hint: "Nested spans in this train.step (forward / backward / optim)", - result: spans_res, - } - StepDetailSection { - title: "Module hooks", - hint: "TorchProbe samples for this step (requires PROBING_TORCH_PROFILING=on)", - result: modules_res, - } - } -} - -#[component] -fn StepDetailSection( - title: &'static str, - hint: &'static str, - result: Result, -) -> Element { - rsx! { - div { class: "space-y-1", - p { class: "text-xs font-medium text-gray-700", "{title}" } - p { class: "text-[10px] text-gray-400", "{hint}" } - match result { - Ok(ref data) if dataframe_rows(data) == 0 => rsx! { - p { class: "text-[10px] text-gray-400 italic py-2", "No data for this step." } - }, - Ok(ref data) => rsx! { - div { class: "overflow-x-auto border border-gray-200 rounded-md max-h-40", - DataFrameView { df: data.clone() } - } - }, - Err(ref e) => rsx! { - p { class: "text-[10px] text-red-600", "{e}" } - }, - } - } - } -} - -#[component] -fn LocalStepMatrixPanel(refresh_tick: u32, selected_step: Signal>) -> Element { - let matrix = use_app_resource(move || { - let _ = refresh_tick; - async move { ApiClient::new().fetch_step_matrix(STEP_LIMIT, false).await } - }); - render_step_matrix_result(&matrix.suspend()?(), selected_step) -} - -#[component] -fn ClusterStepMatrixPanel( - matrix: Result, - selected_step: Signal>, -) -> Element { - render_step_matrix_result(&matrix, selected_step) -} - -#[component] -fn LocalModuleHotspotsPanel(refresh_tick: u32) -> Element { - let modules = use_app_resource(move || { - let _ = refresh_tick; - async move { ApiClient::new().execute_query(MODULE_HOTSPOTS_SQL).await } - }); - let phases = use_app_resource(move || { - let _ = refresh_tick; - async move { ApiClient::new().execute_query(STEP_PHASE_SQL).await } - }); - - let modules_res = modules.suspend()?(); - let phases_res = phases.suspend()?(); - - render_module_hotspots(&modules_res, &phases_res) -} - -#[component] -fn LocalCommPanel(refresh_tick: u32) -> Element { - let comm = use_app_resource(move || { - let _ = refresh_tick; - async move { - ApiClient::new() - .execute_query(&format!("{COMM_SQL}{COMM_LIMIT}")) - .await - } - }); - let summary = use_app_resource(move || { - let _ = refresh_tick; - async move { ApiClient::new().execute_query(COMM_SUMMARY_SQL).await } - }); - - let comm_res = comm.suspend()?(); - let summary_res = summary.suspend()?(); - render_comm_local_result(&comm_res, &summary_res) -} - -#[component] -fn CollapsibleCommPlaceholder() -> Element { - rsx! { - CollapsibleCardWithIcon { - title: "Collective Communications".to_string(), - icon: rsx! { - Icon { icon: &icondata::AiClusterOutlined, class: "w-4 h-4 text-gray-500" } - }, - children: rsx! { - LoadingState { message: Some("Scanning cluster…".to_string()) } - }, - } - } -} - -fn render_comm_cluster_collapsible(result: &Result) -> Element { - match result { - Ok(resp) if dataframe_rows(&resp.dataframe) > 0 => { - let mut note = format!("cluster scan · {} nodes queried", resp.meta.nodes_queried); - if !resp.meta.nodes_failed.is_empty() { - note.push_str(&format!(" · {} failed", resp.meta.nodes_failed.len())); - } - let df = resp.dataframe.clone(); - let rows = dataframe_rows(&df); - let df_for_click = df.clone(); - rsx! { - CollapsibleCardWithIcon { - title: "Collective Communications".to_string(), - badge: Some(format!("{rows} rows")), - accent_border: Some("border-l-violet-400".to_string()), - icon: rsx! { - Icon { icon: &icondata::AiClusterOutlined, class: "w-4 h-4 text-violet-600" } - }, - children: rsx! { - div { class: "space-y-3", - p { class: "text-xs text-gray-500", "{note}" } - p { class: "text-[10px] text-gray-500", - "Click a row to set investigation context (rank / op columns)." - } - div { class: "overflow-x-auto border border-gray-200 rounded-lg max-h-96", - DataFrameView { - df: df.clone(), - on_row_click: EventHandler::new(move |row: usize| { - apply_context_from_dataframe_row(&df_for_click, row); - }), - } - } - p { class: "text-xs text-gray-400", - "Lite mode: timing + context in python.comm_collective · full spans: SET probing.torch.collective.mode=full" - } - } - }, - } - } - } - Ok(_) => rsx! { - CollapsibleCardWithIcon { - title: "Collective Communications".to_string(), - icon: rsx! { - Icon { icon: &icondata::AiClusterOutlined, class: "w-4 h-4 text-gray-500" } - }, - children: rsx! { - EmptyState { message: "No collective rows returned from cluster scan.".to_string() } - }, - } - }, - Err(err) => rsx! { - CollapsibleCardWithIcon { - title: "Collective Communications".to_string(), - icon: rsx! { - Icon { icon: &icondata::AiClusterOutlined, class: "w-4 h-4 text-gray-500" } - }, - children: rsx! { - AppErrorDisplay { error: err.clone(), title: None } - }, - } - }, - } -} - -fn cluster_nodes_failed_banner(nodes: &[String]) -> Element { - if nodes.is_empty() { - return rsx! { div {} }; - } - rsx! { - div { - class: "mb-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950", - p { class: "font-medium", - "Partial cluster scan — {nodes.len()} node(s) did not respond" - } - p { class: "mt-1 text-xs text-amber-800", - "Results below may be incomplete. Check that peers are running and reachable." - } - ul { class: "mt-2 text-xs font-mono text-amber-900 list-disc pl-5 space-y-0.5", - for addr in nodes.iter() { - li { "{addr}" } - } - } - } - } -} - -fn step_summary_stats( - samples: &[StepDurationSample], - single_rank: bool, -) -> Vec<(String, String, Option)> { - if samples.is_empty() { - return Vec::new(); - } - if single_rank { - return single_rank_summary_stats(&build_step_series(samples)); - } - let rank_count = samples.iter().map(|s| s.rank).collect::>().len(); - let step_count = samples - .iter() - .map(|s| s.local_step) - .collect::>() - .len(); - let max_ms = samples.iter().map(|s| s.duration_ms).fold(0.0f64, f64::max); - let outliers = count_outlier_cells(samples); - vec![ - ("Ranks".to_string(), rank_count.to_string(), None), - ("Steps".to_string(), step_count.to_string(), None), - ( - "Max step".to_string(), - if max_ms > 0.0 { - format!("{max_ms:.0} ms") - } else { - "—".to_string() - }, - None, - ), - ("Outliers".to_string(), outliers.to_string(), None), - ] -} - -fn build_step_series(samples: &[StepDurationSample]) -> Vec<(i64, f64)> { - build_step_points(samples) - .into_iter() - .map(|p| (p.display_step, p.duration_ms)) - .collect() -} - -fn build_step_points(samples: &[StepDurationSample]) -> Vec { - let rank = primary_rank(samples); - let mut points: Vec = samples - .iter() - .filter(|s| display_rank(s.rank) == rank && s.local_step >= 0) - .map(|s| StepPoint { - display_step: s.local_step, - coord_step: trace_step(s.coord_step, s.local_step), - duration_ms: s.duration_ms, - }) - .collect(); - points.sort_by_key(|p| p.display_step); - if points.len() > STEP_LIMIT { - points = points[points.len().saturating_sub(STEP_LIMIT)..].to_vec(); - } - points -} - -fn build_coord_lookup(samples: &[StepDurationSample]) -> HashMap<(i32, i64), (i64, f64)> { - let mut lookup = HashMap::new(); - for s in samples { - if s.local_step < 0 { - continue; - } - let rank = display_rank(s.rank); - lookup - .entry((rank, s.local_step)) - .and_modify(|(_, dur)| { - if s.duration_ms > *dur { - *dur = s.duration_ms; - } - }) - .or_insert((trace_step(s.coord_step, s.local_step), s.duration_ms)); - } - lookup -} - -fn percentile(values: &[f64], p: f64) -> f64 { - if values.is_empty() { - return 0.0; - } - let mut v = values.to_vec(); - v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let idx = ((v.len() as f64 - 1.0) * p).round() as usize; - v.get(idx).copied().unwrap_or(0.0) -} - -fn format_step_ms(ms: f64) -> String { - if ms >= 100.0 { - format!("{ms:.0} ms") - } else if ms >= 1.0 { - format!("{ms:.1} ms") - } else if ms > 0.0 { - format!("{ms:.2} ms") - } else { - "0 ms".to_string() - } -} - -fn single_rank_summary_stats(series: &[(i64, f64)]) -> Vec<(String, String, Option)> { - if series.is_empty() { - return Vec::new(); - } - let durations: Vec = series.iter().map(|(_, d)| *d).collect(); - let avg = durations.iter().sum::() / durations.len() as f64; - let max = durations.iter().copied().fold(0.0f64, f64::max); - let p95 = percentile(&durations, 0.95); - let (latest_step, latest_ms) = series.last().copied().unwrap_or((-1, 0.0)); - - vec![ - ( - "Latest".to_string(), - format_step_ms(latest_ms), - Some(format!("step {latest_step}")), - ), - ("Average".to_string(), format_step_ms(avg), None), - ("P95".to_string(), format_step_ms(p95), None), - ("Maximum".to_string(), format_step_ms(max), None), - ] -} - -/// Single-process spans may carry ``rank: -1`` when ``RANK`` was unset; treat as 0. -fn display_rank(rank: i32) -> i32 { - if rank < 0 { - 0 - } else { - rank - } -} - -fn primary_rank(samples: &[StepDurationSample]) -> i32 { - samples - .iter() - .map(|s| display_rank(s.rank)) - .next() - .unwrap_or(0) -} - -fn count_outlier_cells(samples: &[StepDurationSample]) -> usize { - let (_, _, cells, _) = build_heatmap(samples); - cells.values().filter(|c| c.outlier).count() -} - -fn render_step_matrix_result( - result: &Result, - selected_step: Signal>, -) -> Element { - match result { - Ok(resp) if resp.samples.is_empty() => rsx! { - Card { - title: STEP_CARD_TITLE, - EmptyState { - message: "No train.step spans yet. Enable phase hooks with probing.attach_training_phases(model, optimizer) or record train.step spans manually.".to_string() - } - } - }, - Ok(resp) => { - let (ranks, steps, cells, max_ms) = build_heatmap(&resp.samples); - let single_rank = ranks.len() <= 1; - let coord_lookup = build_coord_lookup(&resp.samples); - let scope_note = if resp.cluster { - let mut note = format!("cluster scan · {} nodes queried", resp.nodes_queried); - if !resp.nodes_failed.is_empty() { - note.push_str(&format!(" · {} failed", resp.nodes_failed.len())); - } - note - } else { - "local node · auto-refresh".to_string() - }; - let stats = step_summary_stats(&resp.samples, single_rank); - let rank = primary_rank(&resp.samples); - rsx! { - Card { - title: STEP_CARD_TITLE, - content_class: Some("p-4"), - div { class: "space-y-3", - div { class: "grid grid-cols-4 divide-x divide-gray-200", - for (label, value, hint) in stats { - div { class: "min-w-0 px-4 first:pl-0 last:pr-0", - p { class: "text-[10px] font-medium uppercase tracking-wide text-gray-500", "{label}" } - p { class: "mt-1 truncate text-xl font-semibold text-gray-900", "{value}" } - if let Some(hint) = hint { - p { class: "mt-0.5 text-[10px] text-gray-400", "{hint}" } - } - } - } - } - if single_rank { - StepDurationTimeline { - rank, - points: build_step_points(&resp.samples), - selected_step, - } - } else { - p { class: "text-[10px] text-gray-500", - "{scope_note} · darker cells are slower; outlined cells exceed 1.2× the step median" - } - StepHeatmap { - ranks: ranks.clone(), - steps: steps.clone(), - cells: cells.clone(), - max_ms, - coord_lookup: coord_lookup.clone(), - selected_step, - } - } - } - } - } - } - Err(err) => rsx! { - Card { - title: STEP_CARD_TITLE, - AppErrorDisplay { error: err.clone(), title: None } - } - }, - } -} - -fn render_comm_local_result( - result: &Result, - summary: &Result, -) -> Element { - match result { - Ok(df) if df.cols.is_empty() || dataframe_rows(df) == 0 => rsx! { - CollapsibleCardWithIcon { - title: "Collective Communications".to_string(), - icon: rsx! { - Icon { icon: &icondata::AiClusterOutlined, class: "w-4 h-4 text-gray-500" } - }, - children: rsx! { - EmptyState { - message: "No collective samples on this node. Enable with PROBING_TORCH_COLLECTIVE_ENABLE=1 or SET probing.torch.collective.enable=1.".to_string() - } - }, - } - }, - Ok(df) => { - let summary_df = summary.as_ref().ok().filter(|s| dataframe_rows(s) > 0); - comm_table_collapsible(df, summary_df, "local node · auto-refresh") - } - Err(err) => rsx! { - CollapsibleCardWithIcon { - title: "Collective Communications".to_string(), - icon: rsx! { - Icon { icon: &icondata::AiClusterOutlined, class: "w-4 h-4 text-gray-500" } - }, - children: rsx! { - AppErrorDisplay { error: err.clone(), title: None } - }, - } - }, - } -} - -fn dataframe_rows(df: &probing_proto::prelude::DataFrame) -> usize { - df.row_count() -} - -type HeatmapData = (Vec, Vec, HashMap<(i32, i64), HeatCell>, f64); - -fn build_heatmap(samples: &[StepDurationSample]) -> HeatmapData { - let mut rank_set = HashSet::new(); - let mut step_set = HashSet::new(); - let mut raw: HashMap<(i32, i64), f64> = HashMap::new(); - - for s in samples { - if s.local_step < 0 { - continue; - } - let rank = display_rank(s.rank); - rank_set.insert(rank); - step_set.insert(s.local_step); - raw.entry((rank, s.local_step)) - .and_modify(|v| *v = v.max(s.duration_ms)) - .or_insert(s.duration_ms); - } - - let mut ranks: Vec = rank_set.into_iter().collect(); - ranks.sort(); - let mut steps: Vec = step_set.into_iter().collect(); - steps.sort(); - if steps.len() > 40 { - steps = steps[steps.len().saturating_sub(40)..].to_vec(); - } - - let mut step_medians: HashMap = HashMap::new(); - for step in &steps { - let mut vals: Vec = ranks - .iter() - .filter_map(|r| raw.get(&(*r, *step)).copied()) - .collect(); - if vals.is_empty() { - continue; - } - vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let mid = vals[vals.len() / 2]; - step_medians.insert(*step, mid); - } - - let max_ms = raw.values().copied().fold(0.0f64, f64::max).max(1.0); - let mut cells = HashMap::new(); - for ((rank, step), dur) in raw { - if !steps.contains(&step) { - continue; - } - let median = step_medians.get(&step).copied().unwrap_or(dur); - let outlier = dur > median * 1.2 && ranks.len() > 1; - cells.insert( - (rank, step), - HeatCell { - duration_ms: dur, - outlier, - }, - ); - } - - (ranks, steps, cells, max_ms) -} - -#[component] -fn StepHeatmap( - ranks: Vec, - steps: Vec, - cells: HashMap<(i32, i64), HeatCell>, - max_ms: f64, - coord_lookup: HashMap<(i32, i64), (i64, f64)>, - selected_step: Signal>, -) -> Element { - let featured = ranks.len() <= 1; - let cell_min = if featured { - "min-w-[48px]" - } else { - "min-w-[28px]" - }; - let cell_h = if featured { "h-10" } else { "h-7" }; - - rsx! { - div { class: "overflow-x-auto", - div { - class: "inline-grid gap-1", - style: "grid-template-columns: auto repeat({steps.len()}, minmax(0, 1fr));", - div { class: "text-xs text-gray-400 pr-2 self-end pb-1", "rank \\ step" } - for step in steps.iter() { - div { - class: "text-[10px] text-gray-500 text-center pb-1 font-mono", - "{step}" - } - } - for rank in ranks.iter() { - div { - class: "text-xs font-mono text-gray-600 pr-2 flex items-center justify-end", - "R{rank}" - } - for step in steps.iter() { - { - let cell = cells.get(&(*rank, *step)); - let (bg, title, ring) = if let Some(c) = cell { - let pct = (c.duration_ms / max_ms).clamp(0.0, 1.0); - let alpha = 0.15 + pct * 0.85; - let ring = if c.outlier { - "ring-2 ring-red-500 ring-offset-1" - } else { - "" - }; - ( - format!("background-color: rgba(109, 40, 217, {alpha});"), - format!("rank {rank} step {step}: {:.1} ms — click to set context", c.duration_ms), - ring.to_string(), - ) - } else { - ( - "background-color: rgb(243 244 246);".to_string(), - format!("rank {rank} step {step}: no data"), - String::new(), - ) - }; - let rank_val = *rank; - let step_val = *step; - let clickable = cell.is_some(); - let is_selected = selected_step() - .map(|s| s.rank == rank_val && s.display_step == step_val) - .unwrap_or(false); - let selected_ring = if is_selected { - "ring-2 ring-blue-500 ring-offset-1" - } else { - "" - }; - let (coord_step, duration_ms) = coord_lookup - .get(&(rank_val, step_val)) - .copied() - .unwrap_or((step_val, cell.map(|c| c.duration_ms).unwrap_or(0.0))); - rsx! { - button { - r#type: "button", - disabled: !clickable, - class: "rounded-sm {cell_min} {cell_h} {ring} {selected_ring} disabled:cursor-default", - class: if clickable { "cursor-pointer hover:ring-2 hover:ring-blue-300 hover:ring-offset-1" } else { "" }, - style: "{bg}", - title: "{title}", - onclick: move |_| { - if clickable { - select_training_step( - rank_val, - step_val, - coord_step, - duration_ms, - selected_step, - ); - } - }, - } - } - } - } - } - } - } - } -} - -#[component] -fn StepDurationTimeline( - rank: i32, - points: Vec, - selected_step: Signal>, -) -> Element { - if points.is_empty() { - return rsx! { - p { class: "text-xs text-gray-400 italic py-4 text-center", - "Step samples returned but none matched this rank — try refreshing or check train.step span attributes." - } - }; - } - - let width = 1000.0; - let height = 170.0; - let pad_left = 46.0; - let pad_right = 14.0; - let pad_top = 12.0; - let pad_bottom = 28.0; - let plot_width = width - pad_left - pad_right; - let plot_height = height - pad_top - pad_bottom; - let min_ms = points - .iter() - .map(|point| point.duration_ms) - .fold(f64::INFINITY, f64::min); - let max_ms = points - .iter() - .map(|point| point.duration_ms) - .fold(f64::NEG_INFINITY, f64::max); - let y_padding = ((max_ms - min_ms) * 0.08).max(max_ms.abs() * 0.02).max(0.1); - let y_min = (min_ms - y_padding).max(0.0); - let y_max = max_ms + y_padding; - let y_span = (y_max - y_min).max(0.1); - let avg_ms = points.iter().map(|p| p.duration_ms).sum::() / points.len() as f64; - let latest_idx = points.len().saturating_sub(1); - let point_count_span = latest_idx.max(1) as f64; - let chart_points = points - .iter() - .enumerate() - .map(|(index, point)| { - let x = pad_left + index as f64 / point_count_span * plot_width; - let y = pad_top + (y_max - point.duration_ms) / y_span * plot_height; - (point.clone(), x, y) - }) - .collect::>(); - let line_points = chart_points - .iter() - .map(|(_, x, y)| format!("{x:.1},{y:.1}")) - .collect::>() - .join(" "); - let avg_y = pad_top + (y_max - avg_ms) / y_span * plot_height; - let first_step = points - .first() - .map(|point| point.display_step) - .unwrap_or_default(); - let last_step = points - .last() - .map(|point| point.display_step) - .unwrap_or_default(); - rsx! { - div { class: "border-t border-gray-100 pt-3", - div { class: "mb-1 flex items-center justify-between gap-2 text-[10px] text-gray-500", - span { "Recent steps" } - span { class: "font-mono", "rank {rank}" } - } - svg { - class: "h-44 w-full", - view_box: "0 0 {width} {height}", - preserve_aspect_ratio: "none", - for tick in 0..=3 { - { - let ratio = tick as f64 / 3.0; - let y = pad_top + ratio * plot_height; - let tick_value = y_max - ratio * y_span; - rsx! { - line { - x1: "{pad_left}", y1: "{y}", - x2: "{pad_left + plot_width}", y2: "{y}", - stroke: "#e5e7eb", stroke_width: "1", - } - text { - x: "{pad_left - 6.0}", y: "{y + 3.0}", - text_anchor: "end", font_size: "9", fill: "#9ca3af", - "{format_step_ms(tick_value)}" - } - } - } - } - line { - x1: "{pad_left}", y1: "{avg_y}", - x2: "{pad_left + plot_width}", y2: "{avg_y}", - stroke: "#9ca3af", stroke_width: "1", stroke_dasharray: "4 4", - } - polyline { - points: "{line_points}", - fill: "none", - stroke: "#2563eb", - stroke_width: "2.5", - stroke_linejoin: "round", - stroke_linecap: "round", - vector_effect: "non-scaling-stroke", - } - for (index, (point, x, y)) in chart_points.iter().enumerate() { - { - let step_val = point.display_step; - let coord_step = point.coord_step; - let duration_ms = point.duration_ms; - let is_selected = selected_step() - .map(|selected| selected.rank == rank && selected.display_step == step_val) - .unwrap_or(false); - let visible = is_selected || index == latest_idx; - rsx! { - circle { - cx: "{x}", cy: "{y}", r: if visible { "4" } else { "7" }, - fill: if visible { "#2563eb" } else { "transparent" }, - stroke: if is_selected { "#1e3a8a" } else { "transparent" }, - stroke_width: "2", - class: "cursor-pointer", - onclick: move |_| { - select_training_step( - rank, - step_val, - coord_step, - duration_ms, - selected_step, - ); - }, - title { "step {step_val}: {duration_ms:.1} ms" } - } - } - } - } - text { - x: "{pad_left}", y: "{height - 6.0}", - text_anchor: "start", font_size: "9", fill: "#9ca3af", "step {first_step}" - } - text { - x: "{pad_left + plot_width}", y: "{height - 6.0}", - text_anchor: "end", font_size: "9", fill: "#9ca3af", "step {last_step}" - } - } - div { class: "mt-1 flex justify-end gap-4 text-[9px] text-gray-400", - span { class: "flex items-center gap-1", - span { class: "inline-block h-px w-4 bg-blue-600" } - "duration" - } - span { class: "flex items-center gap-1", - span { class: "inline-block w-4 border-t border-dashed border-gray-400" } - "average" - } - } - } - } -} - -fn render_module_hotspots( - modules: &Result, - phases: &Result, -) -> Element { - let has_modules = modules - .as_ref() - .ok() - .map(|df| dataframe_rows(df) > 0) - .unwrap_or(false); - let has_phases = phases - .as_ref() - .ok() - .map(|df| dataframe_rows(df) > 0) - .unwrap_or(false); - - if !has_modules && !has_phases { - return rsx! { - CollapsibleCardWithIcon { - title: "Module Hotspots".to_string(), - icon: rsx! { - Icon { icon: &icondata::AiFireOutlined, class: "w-4 h-4 text-gray-500" } - }, - children: rsx! { - EmptyState { - message: "No python.torch_trace data — SET probing.torch.profiling=on for module-level step breakdown.".to_string() - } - }, - } - }; - } - - if let Err(err) = modules { - return rsx! { - CollapsibleCardWithIcon { - title: "Module Hotspots".to_string(), - icon: rsx! { - Icon { icon: &icondata::AiFireOutlined, class: "w-4 h-4 text-gray-500" } - }, - children: rsx! { - AppErrorDisplay { error: err.clone(), title: None } - }, - } - }; - } - - let row_count = modules.as_ref().ok().map(dataframe_rows).unwrap_or(0); - - rsx! { - CollapsibleCardWithIcon { - title: "Module Hotspots".to_string(), - badge: if row_count > 0 { Some(format!("{row_count} modules")) } else { None }, - accent_border: Some("border-l-orange-400".to_string()), - default_open: true, - icon: rsx! { - Icon { icon: &icondata::AiFireOutlined, class: "w-4 h-4 text-orange-600" } - }, - children: rsx! { - div { class: "space-y-4", - p { class: "text-xs text-gray-500", - "Top modules by post-hook time in the last 10 training steps · steps = distinct steps where this module was sampled · hooks = raw hook records (TorchProbe random mode samples a subset of modules each step)" - } - if has_modules { - if let Ok(df) = modules { - div { class: "overflow-x-auto border border-gray-200 rounded-lg max-h-72", - DataFrameView { df: df.clone() } - } - } - } - if has_phases { - div { class: "space-y-2", - p { class: "text-xs font-medium text-gray-700", "Forward vs optimizer (recent steps)" } - if let Ok(df) = phases { - div { class: "overflow-x-auto border border-gray-200 rounded-lg max-h-48", - DataFrameView { df: df.clone() } - } - } - } - } - p { class: "text-xs text-gray-400", - "Select a slow step above, or use Investigate → Bottleneck skill for deeper analysis." - } - } - }, - } - } -} - -fn comm_table_collapsible( - df: &probing_proto::prelude::DataFrame, - summary: Option<&probing_proto::prelude::DataFrame>, - scope_note: &str, -) -> Element { - let rows = dataframe_rows(df); - let df_for_click = df.clone(); - rsx! { - CollapsibleCardWithIcon { - title: "Collective Communications".to_string(), - badge: Some(format!("{rows} rows")), - accent_border: Some("border-l-violet-400".to_string()), - icon: rsx! { - Icon { icon: &icondata::AiClusterOutlined, class: "w-4 h-4 text-violet-600" } - }, - children: rsx! { - div { class: "space-y-3", - p { class: "text-xs text-gray-500", "{scope_note}" } - if let Some(summary_df) = summary { - div { class: "space-y-2", - p { class: "text-xs font-medium text-gray-700", "By collective op (aggregated)" } - div { class: "overflow-x-auto border border-gray-200 rounded-lg", - DataFrameView { df: summary_df.clone() } - } - } - } - p { class: "text-[10px] text-gray-500", - "Click a row to set investigation context (rank / op columns)." - } - div { class: "overflow-x-auto border border-gray-200 rounded-lg max-h-96", - DataFrameView { - df: df.clone(), - on_row_click: EventHandler::new(move |row: usize| { - apply_context_from_dataframe_row(&df_for_click, row); - }), - } - } - p { class: "text-xs text-gray-400", - "Lite mode: timing + context in python.comm_collective · full spans: SET probing.torch.collective.mode=full" - } - } - }, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn placement_node(host: &str, rank: i32, local_rank: i32, role: Option<&str>) -> Node { - Node { - host: host.to_string(), - addr: format!("127.0.0.1:{}", 9000 + rank), - rank: Some(rank), - local_rank: Some(local_rank), - world_size: Some(4), - role: role.map(str::to_string), - status: Some("running".to_string()), - ..Default::default() - } - } - - #[test] - fn parallel_coordinates_use_stable_megatron_order() { - let coordinates = parse_parallel_coordinates(Some("tp=1, dp=2,ignored=x,pp=0,sp=1")); - - assert_eq!( - coordinates, - vec![ - ("dp".to_string(), "2".to_string()), - ("pp".to_string(), "0".to_string()), - ("tp".to_string(), "1".to_string()), - ("sp".to_string(), "1".to_string()), - ] - ); - } - - #[test] - fn placement_matches_cpu_megatron_mock_topology() { - let nodes = vec![ - placement_node("cpu-worker", 3, 3, Some("dp=0,pp=1,tp=1")), - placement_node("cpu-worker", 1, 1, Some("dp=0,pp=0,tp=1")), - placement_node("cpu-worker", 2, 2, Some("dp=0,pp=1,tp=0")), - placement_node("cpu-worker", 0, 0, Some("dp=0,pp=0,tp=0")), - ]; - - let placement = build_placement(&nodes); - - assert_eq!(placement.observed_ranks, 4); - assert_eq!(placement.expected_ranks, 4); - assert!(placement.has_parallel_coordinates); - assert_eq!(placement.hosts.len(), 1); - assert_eq!(placement.hosts[0].name, "cpu-worker"); - assert_eq!(placement.hosts[0].processes[0].rank, Some(0)); - assert_eq!(placement.hosts[0].processes[3].rank, Some(3)); - assert_eq!( - placement.parallel_sizes, - vec![ - ("dp".to_string(), 1), - ("pp".to_string(), 2), - ("tp".to_string(), 2), - ] - ); - assert_eq!( - placement.hosts[0].processes[3].coordinates, - vec![ - ("dp".to_string(), "0".to_string()), - ("pp".to_string(), "1".to_string()), - ("tp".to_string(), "1".to_string()), - ] - ); - } - - #[test] - fn placement_summarizes_64_rank_megatron_topology() { - let nodes = (0..64) - .map(|rank| { - let tp = rank % 2; - let pp = (rank / 2) % 4; - let dp = rank / 8; - let mut node = placement_node( - &format!("worker-{:02}", rank / 8), - rank, - rank % 8, - Some(&format!("dp={dp},pp={pp},sp={tp},tp={tp}")), - ); - node.world_size = Some(64); - node - }) - .collect::>(); - - let placement = build_placement(&nodes); - - assert_eq!(placement.hosts.len(), 8); - assert_eq!(placement_host_columns(placement.hosts.len()), 8); - assert!(placement.hosts.iter().all(|host| host.processes.len() == 8)); - assert_eq!(placement.observed_ranks, 64); - assert_eq!(placement.expected_ranks, 64); - assert_eq!( - placement.parallel_sizes, - vec![ - ("dp".to_string(), 8), - ("pp".to_string(), 4), - ("tp".to_string(), 2), - ("sp".to_string(), 2), - ] - ); - assert_eq!(placement.hosts[7].processes[7].rank, Some(63)); - assert_eq!( - placement.hosts[7].processes[7].coordinates, - vec![ - ("dp".to_string(), "7".to_string()), - ("pp".to_string(), "3".to_string()), - ("tp".to_string(), "1".to_string()), - ("sp".to_string(), "1".to_string()), - ] - ); - - let active = &placement.hosts[0].processes[0]; - assert_eq!( - placement_group_membership(active, &placement.hosts[0].processes[0]), - Some(PlacementGroup::Focus) - ); - assert_eq!( - placement_group_membership(active, &placement.hosts[0].processes[1]), - Some(PlacementGroup::Tensor) - ); - assert_eq!( - placement_group_membership(active, &placement.hosts[0].processes[2]), - Some(PlacementGroup::Pipeline) - ); - assert_eq!( - placement_group_membership(active, &placement.hosts[1].processes[0]), - Some(PlacementGroup::Data) - ); - assert_eq!( - placement_group_membership(active, &placement.hosts[0].processes[3]), - None - ); - assert_eq!( - placement_group_sizes(&placement, active), - Some(PlacementGroupSizes { - tensor: 2, - data: 8, - pipeline: 4, - }) - ); - } - - #[test] - fn placement_wraps_after_eight_host_columns() { - assert_eq!(placement_host_columns(1), 1); - assert_eq!(placement_host_columns(8), 8); - assert_eq!(placement_host_columns(9), 8); - assert_eq!(placement_host_columns(32), 8); - } -} diff --git a/web/src/rl_contract.rs b/web/src/rl_contract.rs deleted file mode 100644 index cd29aa5f..00000000 --- a/web/src/rl_contract.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Framework-neutral RL span classification rules. -//! -//! Any RL framework can drive the Probing UI by attaching standard span attributes -//! (see `probing.rl.STANDARD_ATTRS` in the Python SDK). The frontend only interprets -//! these generic fields — never framework-specific names. - -use crate::api::SpanInfo; - -pub fn attr_string(attributes: &Option, key: &str) -> Option { - let attrs = attributes.as_ref()?; - let value = serde_json::from_str::(attrs).ok()?; - let raw = value.get(key)?; - match raw { - serde_json::Value::String(s) => Some(s.clone()), - serde_json::Value::Number(n) => Some(n.to_string()), - serde_json::Value::Bool(b) => Some(b.to_string()), - _ => None, - } -} - -/// Rollout/sample timeline requires trajectory or sample identity on each span. -pub fn has_trajectory_identity(attributes: &Option) -> bool { - attr_string(attributes, "trajectory_id").is_some() - || attr_string(attributes, "sample_id").is_some() -} - -/// Training timeline spans must be tagged as train phases and keyed by step/batch. -pub fn is_train_phase_span( - name: &str, - phase: &str, - kind: Option<&str>, - attributes: &Option, -) -> bool { - if phase.starts_with("train") || name.starts_with("train.") { - return true; - } - if attr_string(attributes, "actor_role").as_deref() == Some("trainer") { - return true; - } - matches!( - kind, - Some("rl.train") | Some("train.step") | Some("rl.phase") - ) && (phase.contains("train") || name.contains("train")) -} - -pub fn is_train_timeline_span( - name: &str, - phase: &str, - kind: Option<&str>, - attributes: &Option, -) -> bool { - if attr_string(attributes, "train_step_id").is_none() - && attr_string(attributes, "batch_id").is_none() - { - return false; - } - is_train_phase_span(name, phase, kind, attributes) -} - -/// Parent span that groups a distributed rollout/train step across processes. -pub fn is_step_parent_span(name: &str, kind: Option<&str>) -> bool { - name == "rollout.step" - || name == "train.step" - || name == "rollout.submit" - || name == "rollout.submit_next" - || matches!(kind, Some("rl.step") | Some("train.step")) -} - -pub fn is_rollout_submit_parent_span(name: &str) -> bool { - name == "rollout.submit" || name == "rollout.submit_next" -} - -/// Worker process that executes rollout logic (framework sets `actor_role` or `process_role`). -pub fn is_rollout_worker_role(role: &str) -> bool { - role == "rollout" - || role == "rollout_actor" - || role.ends_with("_rollout") - || role.contains("rollout_worker") -} - -pub fn is_cross_process_child_candidate(span: &SpanInfo) -> bool { - if logical_step_key(span).is_none() || is_cross_process_parent_span(span) { - return false; - } - - let role = attr_string(&span.attributes, "process_role") - .or_else(|| attr_string(&span.attributes, "actor_role")) - .unwrap_or_default(); - is_rollout_worker_role(&role) || span.name.starts_with("custom.generate") -} - -pub fn is_cross_process_parent_span(span: &SpanInfo) -> bool { - is_step_parent_span(&span.name, span.phase.as_deref()) -} - -pub fn logical_step_key(span: &SpanInfo) -> Option { - let rollout_id = attr_string(&span.attributes, "rollout_id") - .or_else(|| attr_string(&span.attributes, "step_id"))?; - let step_id = attr_string(&span.attributes, "step_id") - .or_else(|| attr_string(&span.attributes, "train_step_id"))?; - - if rollout_id == "-1" || step_id == "-1" { - return None; - } - - Some(LogicalStepKey { - rollout_id, - step_id, - }) -} - -#[derive(Clone, Eq, PartialEq)] -pub struct LogicalStepKey { - pub rollout_id: String, - pub step_id: String, -} - -/// User-facing hint when train timeline has no matching spans. -pub const TRAIN_TIMELINE_EMPTY_HINT: &str = - "No train batch spans found. Tag spans with rollout_id, train_step_id or batch_id, and phase=train.* via probing.rl."; - -/// User-facing hint when rollout timeline has no matching spans. -pub const ROLLOUT_TIMELINE_EMPTY_HINT: &str = - "No rollout trajectory spans found. Tag spans with rollout_id plus trajectory_id or sample_id via probing.rl."; diff --git a/web/src/state/commands.rs b/web/src/state/commands.rs index fa3b5053..fa792dcf 100644 --- a/web/src/state/commands.rs +++ b/web/src/state/commands.rs @@ -26,5 +26,5 @@ pub struct FloatingResult { pub static COMMAND_PANEL_OPEN: GlobalSignal = Signal::global(|| false); pub static SHORTCUTS_HELP_OPEN: GlobalSignal = Signal::global(|| false); -pub static COMMAND_INPUT: GlobalSignal = Signal::global(String::new); pub static EVAL_HISTORY: GlobalSignal> = Signal::global(Vec::new); +pub static FLOATING_RESULT: GlobalSignal> = Signal::global(|| None); diff --git a/web/src/state/investigation.rs b/web/src/state/investigation.rs index c3d6dcfe..993a8ab0 100644 --- a/web/src/state/investigation.rs +++ b/web/src/state/investigation.rs @@ -139,18 +139,6 @@ pub fn clear_profiling_thread_filter() { *PROFILING_THREAD_FILTER.write() = None; } -/// Clear span-tree filters while keeping process context (pid). -pub fn clear_spans_investigation_filters() { - let pid = INVESTIGATION_CONTEXT.read().pid; - clear_investigation_context(); - if let Some(pid) = pid { - update_investigation_context(|ctx| { - ctx.pid = Some(pid); - ctx.label = Some(format!("pid {pid}")); - }); - } -} - /// Stable key for detecting external investigation context changes. pub fn investigation_context_key(ctx: &InvestigationContext) -> String { format!( @@ -166,127 +154,6 @@ pub fn investigation_context_key(ctx: &InvestigationContext) -> String { ) } -/// Write Spans page filters back into global context (and URL). -pub fn sync_spans_filters_to_context( - name_filter: &str, - thread_filter: &str, - trace_id_filter: &str, -) { - update_investigation_context(|ctx| { - let name = name_filter.trim(); - ctx.span_name = if name.is_empty() { - None - } else { - Some(name.to_string()) - }; - - let thread = thread_filter.trim(); - if thread.is_empty() { - ctx.tid = None; - } else if let Ok(tid) = thread.parse::() { - ctx.tid = Some(tid); - } - - let trace = trace_id_filter.trim(); - if trace.is_empty() { - ctx.trace_id = None; - } else if let Ok(trace_id) = trace.parse::() { - ctx.trace_id = Some(trace_id); - } - - ctx.label = if ctx.is_empty() { - None - } else { - Some(ctx.derived_summary()) - }; - }); -} - -fn column_index(names: &[String], candidates: &[&str]) -> Option { - names.iter().position(|name| { - let lower = name.to_lowercase(); - candidates - .iter() - .any(|c| lower == *c || lower.ends_with(&format!("_{c}"))) - }) -} - -fn cell_i32(df: &probing_proto::prelude::DataFrame, row: usize, col: usize) -> Option { - use probing_proto::prelude::Ele; - match df.cols.get(col)?.get(row) { - Ele::I32(v) => Some(v), - Ele::I64(v) => i32::try_from(v).ok(), - Ele::Text(s) => s.parse().ok(), - _ => None, - } -} - -fn cell_i64(df: &probing_proto::prelude::DataFrame, row: usize, col: usize) -> Option { - use probing_proto::prelude::Ele; - match df.cols.get(col)?.get(row) { - Ele::I64(v) => Some(v), - Ele::I32(v) => Some(v as i64), - Ele::Text(s) => s.parse().ok(), - _ => None, - } -} - -fn cell_text(df: &probing_proto::prelude::DataFrame, row: usize, col: usize) -> Option { - use probing_proto::prelude::Ele; - match df.cols.get(col)?.get(row) { - Ele::Text(s) if !s.is_empty() => Some(s.clone()), - Ele::I32(v) => Some(v.to_string()), - Ele::I64(v) => Some(v.to_string()), - _ => None, - } -} - -/// Apply investigation context from an Agent/SQL result row (tid, trace_id, span name columns). -pub fn apply_context_from_dataframe_row(df: &probing_proto::prelude::DataFrame, row: usize) { - let tid = - column_index(&df.names, &["tid", "thread_id", "thread"]).and_then(|c| cell_i32(df, row, c)); - let trace_id = - column_index(&df.names, &["trace_id", "trace"]).and_then(|c| cell_i64(df, row, c)); - let span_name = column_index(&df.names, &["span_name", "span", "name", "operation", "op"]) - .and_then(|c| cell_text(df, row, c)); - let pid = column_index(&df.names, &["pid", "process_id"]).and_then(|c| cell_i32(df, row, c)); - let rank = column_index(&df.names, &["rank", "_rank"]).and_then(|c| cell_i32(df, row, c)); - let local_step = - column_index(&df.names, &["local_step", "step"]).and_then(|c| cell_i64(df, row, c)); - - if tid.is_none() && trace_id.is_none() && span_name.is_none() && pid.is_none() && rank.is_none() - { - return; - } - - update_investigation_context(|ctx| { - if let Some(p) = pid { - ctx.pid = Some(p); - } - if let Some(t) = tid { - ctx.tid = Some(t); - } - if let Some(id) = trace_id { - ctx.trace_id = Some(id); - } - if let Some(name) = span_name { - ctx.span_name = Some(name); - } - if let Some(r) = rank { - let mut label = format!("rank {r}"); - if let Some(step) = local_step { - label.push_str(&format!(" · step {step}")); - } - if let Some(ref op) = ctx.span_name { - label.push_str(&format!(" · {op}")); - } - ctx.label = Some(label); - } else { - ctx.label = Some(ctx.derived_summary()); - } - }); -} - /// Pin investigation context to a train.step heatmap cell (rank + optional step). pub fn set_training_step_context(rank: i32, local_step: Option, host: Option<&str>) { let mut label = format!("rank {rank}"); @@ -341,22 +208,6 @@ pub fn set_node_context(rank: Option, host: Option<&str>, device_id: Option }); } -pub fn set_thread_context(tid: i32, thread_name: Option<&str>, pid: Option) { - let label = match thread_name { - Some(name) if !name.is_empty() => format!("thread {tid} · {name}"), - _ => format!("thread {tid}"), - }; - update_investigation_context(|ctx| { - ctx.tid = Some(tid); - ctx.pid = pid.or(ctx.pid); - ctx.trace_id = None; - ctx.span_name = None; - ctx.local_step = None; - ctx.label = Some(label); - }); - *PROFILING_THREAD_FILTER.write() = Some(tid); -} - pub fn set_trace_context(trace_id: i64, span_name: Option<&str>, tid: Option) { update_investigation_context(|ctx| { ctx.trace_id = Some(trace_id); @@ -367,16 +218,6 @@ pub fn set_trace_context(trace_id: i64, span_name: Option<&str>, tid: Option Option { - match app_overlay() { - Some(AppOverlay::Monitor(m)) => Some(m), - _ => None, - } -} - pub fn open_source_viewer(path: String, line: Option) { open_app_overlay(AppOverlay::SourceViewer(SourceViewerTarget { path, line })); } diff --git a/web/src/state/page_context.rs b/web/src/state/page_context.rs index 688002fa..a0200260 100644 --- a/web/src/state/page_context.rs +++ b/web/src/state/page_context.rs @@ -2,8 +2,6 @@ use dioxus::prelude::*; -use crate::app::Route; - #[derive(Clone, Debug, Default, PartialEq)] pub struct PageContext { pub page_id: String, @@ -78,9 +76,6 @@ impl PageContext { pub static PAGE_CONTEXT: GlobalSignal = Signal::global(PageContext::default); -/// Active route (for snapshot refresh before Agent LLM calls). -pub static CURRENT_ROUTE: GlobalSignal> = Signal::global(|| None); - fn commit_page_context(ctx: PageContext) { if ctx == *PAGE_CONTEXT.read() { return; diff --git a/web/src/state/profiling.rs b/web/src/state/profiling.rs index 466627e8..fc4a2e9a 100644 --- a/web/src/state/profiling.rs +++ b/web/src/state/profiling.rs @@ -19,10 +19,6 @@ pub fn normalize_profiling_view(view: &str) -> &'static str { } } -pub fn profiling_view_label(view: &str) -> &'static str { - profiling_view_spec(view).label -} - /// Apply server `df_settings` rows to global profiling UI state. pub fn apply_profiler_config(config: &[(String, String)]) { *PROFILING_PPROF_FREQ.write() = 0; @@ -85,40 +81,28 @@ pub fn clear_profiling_feedback() { pub struct ProfilingViewSpec { pub id: &'static str, pub label: &'static str, - pub sidebar_label: &'static str, - pub tooltip: &'static str, } pub const PROFILING_VIEWS: &[ProfilingViewSpec] = &[ ProfilingViewSpec { id: "pprof", label: "CPU sampling", - sidebar_label: "CPU (pprof)", - tooltip: "SIGPROF stack sampling · statistical flamegraph", }, ProfilingViewSpec { id: "torch", label: "Torch modules", - sidebar_label: "Torch flamegraph", - tooltip: "PyTorch module hook durations · statistical flamegraph (not the profiler timeline)", }, ProfilingViewSpec { id: "trace", label: "Chrome trace", - sidebar_label: "Chrome trace", - tooltip: "Chrome trace event timeline from probing trace buffers (not distributed spans on the Spans page)", }, ProfilingViewSpec { id: "pytorch", label: "PyTorch profiler", - sidebar_label: "PyTorch timeline", - tooltip: "PyTorch profiler chrome trace export", }, ProfilingViewSpec { id: "ray", label: "Ray timeline", - sidebar_label: "Ray timeline", - tooltip: "Ray task and actor timeline", }, ]; diff --git a/web/src/state/rl.rs b/web/src/state/rl.rs index 4a73c0ca..9b26a0b2 100644 --- a/web/src/state/rl.rs +++ b/web/src/state/rl.rs @@ -5,15 +5,3 @@ use dioxus::prelude::*; pub static ROLLOUT_FILTER: GlobalSignal = Signal::global(String::new); pub static ROLLOUT_FILTER_INPUT: GlobalSignal = Signal::global(String::new); pub static RL_EVENT_LIMIT: GlobalSignal = Signal::global(|| 400); - -/// Height of the pinned rollout/train detail panel (px). Synced for main content padding. -pub static RL_DETAIL_PANEL_HEIGHT: GlobalSignal = Signal::global(|| 256); - -pub const RL_DETAIL_PANEL_HEIGHT_DEFAULT: u32 = 256; -pub const RL_DETAIL_PANEL_HEIGHT_MIN: u32 = 120; -pub const RL_DETAIL_PANEL_HEIGHT_MAX: u32 = 1200; - -pub fn estimate_detail_panel_height(segment_count: usize) -> u32 { - let estimated = 108u32.saturating_add(segment_count as u32 * 44); - estimated.clamp(RL_DETAIL_PANEL_HEIGHT_MIN, RL_DETAIL_PANEL_HEIGHT_MAX) -} diff --git a/web/src/state/sidebar.rs b/web/src/state/sidebar.rs deleted file mode 100644 index 6e20a3f0..00000000 --- a/web/src/state/sidebar.rs +++ /dev/null @@ -1,34 +0,0 @@ -use dioxus::prelude::*; - -pub static SIDEBAR_WIDTH: GlobalSignal = Signal::global(|| 256.0); -pub static SIDEBAR_HIDDEN: GlobalSignal = Signal::global(|| false); - -/// Load sidebar width and hidden state from localStorage into global signals. -pub fn load_sidebar_state() { - if let Some(w) = web_sys::window() { - if let Some(storage) = w.local_storage().ok().flatten() { - if let Ok(Some(width_str)) = storage.get_item("sidebar_width") { - if let Ok(width) = width_str.parse::() { - if (200.0..=600.0).contains(&width) { - *SIDEBAR_WIDTH.write() = width; - } - } - } - if let Ok(Some(hidden_str)) = storage.get_item("sidebar_hidden") { - if hidden_str == "true" { - *SIDEBAR_HIDDEN.write() = true; - } - } - } - } -} - -/// Persist current sidebar width and hidden state to localStorage. -pub fn save_sidebar_state() { - if let Some(w) = web_sys::window() { - if let Some(storage) = w.local_storage().ok().flatten() { - let _ = storage.set_item("sidebar_width", &SIDEBAR_WIDTH.read().to_string()); - let _ = storage.set_item("sidebar_hidden", &SIDEBAR_HIDDEN.read().to_string()); - } - } -} diff --git a/web/src/ui_version.rs b/web/src/ui_version.rs deleted file mode 100644 index 65da2937..00000000 --- a/web/src/ui_version.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Classic/next UI selection. -//! -//! Only one application root is mounted at a time. Switching versions persists -//! the preference and reloads the page so hooks, global signals, and listeners -//! from the previous UI cannot leak into the next one. - -use dioxus::prelude::*; - -#[cfg(target_arch = "wasm32")] -const UI_VERSION_KEY: &str = "probing.ui.version"; - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum UiVersion { - Classic, - #[default] - Next, -} - -impl UiVersion { - pub const fn as_str(self) -> &'static str { - match self { - Self::Classic => "classic", - Self::Next => "next", - } - } - - const fn other(self) -> Self { - match self { - Self::Classic => Self::Next, - Self::Next => Self::Classic, - } - } - - #[cfg(any(target_arch = "wasm32", test))] - fn parse(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { - "classic" | "legacy" | "v1" => Some(Self::Classic), - "next" | "v2" => Some(Self::Next), - _ => None, - } - } -} - -#[cfg(any(target_arch = "wasm32", test))] -fn version_from_search(search: &str) -> Option { - search - .trim_start_matches('?') - .split('&') - .filter_map(|pair| pair.split_once('=')) - .find_map(|(key, value)| (key == "ui").then(|| UiVersion::parse(value)).flatten()) -} - -#[cfg(any(target_arch = "wasm32", test))] -fn search_with_version(search: &str, version: UiVersion) -> String { - let mut pairs = search - .trim_start_matches('?') - .split('&') - .filter(|pair| !pair.is_empty()) - .filter(|pair| pair.split_once('=').is_none_or(|(key, _)| key != "ui")) - .map(str::to_string) - .collect::>(); - pairs.push(format!("ui={}", version.as_str())); - format!("?{}", pairs.join("&")) -} - -#[cfg(target_arch = "wasm32")] -fn persist(version: UiVersion) { - let Some(window) = web_sys::window() else { - return; - }; - if let Ok(Some(storage)) = window.local_storage() { - let _ = storage.set_item(UI_VERSION_KEY, version.as_str()); - } -} - -#[cfg(not(target_arch = "wasm32"))] -fn persist(_version: UiVersion) {} - -fn initial_version() -> UiVersion { - #[cfg(target_arch = "wasm32")] - { - let Some(window) = web_sys::window() else { - return UiVersion::default(); - }; - if let Ok(search) = window.location().search() { - if let Some(version) = version_from_search(&search) { - persist(version); - return version; - } - } - if let Ok(Some(storage)) = window.local_storage() { - if let Ok(Some(value)) = storage.get_item(UI_VERSION_KEY) { - if let Some(version) = UiVersion::parse(&value) { - return version; - } - } - } - } - UiVersion::default() -} - -pub fn activate(version: UiVersion) { - persist(version); - #[cfg(target_arch = "wasm32")] - { - let Some(window) = web_sys::window() else { - return; - }; - let location = window.location(); - let search = location.search().unwrap_or_default(); - if location - .set_search(&search_with_version(&search, version)) - .is_err() - { - let _ = location.reload(); - } - } -} - -pub fn href_for(path: &str, version: UiVersion) -> String { - let path = crate::utils::base_path::with_base(path); - let separator = if path.contains('?') { '&' } else { '?' }; - format!("{path}{separator}ui={}", version.as_str()) -} - -#[component] -pub fn RootApp() -> Element { - let version = use_hook(initial_version); - rsx! { - match version { - UiVersion::Classic => rsx! { crate::app::App {} }, - UiVersion::Next => rsx! { crate::next::NextApp {} }, - } - UiVersionSwitch { current: version } - } -} - -#[component] -fn UiVersionSwitch(current: UiVersion) -> Element { - if current == UiVersion::Next { - return rsx! {}; - } - let target = current.other(); - rsx! { - button { - r#type: "button", - class: "fixed bottom-4 right-4 z-[10050] rounded-full border border-blue-300 \ - bg-white/95 px-3 py-1.5 text-xs font-medium text-blue-700 shadow-lg \ - backdrop-blur hover:bg-blue-50 focus:outline-none focus:ring-2 \ - focus:ring-blue-500 focus:ring-offset-2", - title: "Switch Probing web interface", - onclick: move |_| activate(target), - "Try next UI" - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn next_is_the_default_interface() { - assert_eq!(UiVersion::default(), UiVersion::Next); - assert_eq!(initial_version(), UiVersion::Next); - } - - #[test] - fn query_version_has_aliases() { - assert_eq!(version_from_search("?ui=next"), Some(UiVersion::Next)); - assert_eq!( - version_from_search("?trace=abc&ui=v1"), - Some(UiVersion::Classic) - ); - assert_eq!(version_from_search("?ui=unknown"), None); - } - - #[test] - fn replacing_version_preserves_other_query_parameters() { - assert_eq!( - search_with_version("?trace_id=abc&ui=classic&rank=7", UiVersion::Next), - "?trace_id=abc&rank=7&ui=next" - ); - assert_eq!(search_with_version("", UiVersion::Classic), "?ui=classic"); - } -} diff --git a/web/src/utils/source_ref.rs b/web/src/utils/source_ref.rs index a568a3ea..9f6aff07 100644 --- a/web/src/utils/source_ref.rs +++ b/web/src/utils/source_ref.rs @@ -95,6 +95,7 @@ fn looks_like_source_path(path: &str) -> bool { } /// Parse `GET /apis/files?path=…` into a local path. +#[cfg(test)] pub fn parse_files_api_path(api_path: &str) -> Option { if !api_path.contains("/apis/files") { return None;