From 9304f5fa3520f47a00f1d44c8877d50f39288508 Mon Sep 17 00:00:00 2001 From: Reiase Date: Mon, 23 Feb 2026 11:37:16 +0800 Subject: [PATCH 1/7] Refactor: streamline sidebar and component structure - Removed the old sidebar implementation and replaced it with a new modular structure in `sidebar/mod.rs`, enhancing maintainability and readability. - Introduced new components for sidebar navigation items and resizing functionality. - Updated various components to utilize the new sidebar structure, improving overall UI consistency. - Enhanced styling and layout for better user experience across the application. --- web/assets/tailwind.css | 23 - web/src/api/trace.rs | 3 + web/src/api/traces.rs | 4 +- web/src/app.rs | 24 +- web/src/components/card.rs | 11 +- web/src/components/chrome_tracing_iframe.rs | 74 ++ web/src/components/collapsible_card.rs | 6 +- web/src/components/colors.rs | 13 + web/src/components/common.rs | 33 +- web/src/components/data.rs | 2 +- web/src/components/layout.rs | 35 +- web/src/components/mod.rs | 15 + web/src/components/page.rs | 19 +- web/src/components/sidebar.rs | 731 ------------------ web/src/components/sidebar/mod.rs | 148 ++++ web/src/components/sidebar/nav_item.rs | 45 ++ .../components/sidebar/profiling/controls.rs | 288 +++++++ web/src/components/sidebar/profiling/mod.rs | 183 +++++ web/src/components/sidebar/resize.rs | 57 ++ web/src/components/table_view.rs | 18 +- web/src/hooks.rs | 4 +- web/src/main.rs | 2 +- web/src/pages/analytics.rs | 16 +- web/src/pages/chrome_tracing.rs | 654 +--------------- web/src/pages/cluster.rs | 7 +- web/src/pages/dashboard.rs | 93 ++- web/src/pages/profiling.rs | 237 ++---- web/src/pages/python.rs | 42 +- web/src/pages/stack.rs | 10 +- web/src/pages/traces.rs | 8 +- web/src/state/mod.rs | 2 + web/src/state/profiling.rs | 12 + web/src/state/sidebar.rs | 34 + web/src/styles.rs | 3 - web/src/utils/error.rs | 7 + web/src/utils/mod.rs | 1 + web/src/utils/tracing_viewer.rs | 207 +++++ 37 files changed, 1398 insertions(+), 1673 deletions(-) create mode 100644 web/src/components/chrome_tracing_iframe.rs delete mode 100644 web/src/components/sidebar.rs create mode 100644 web/src/components/sidebar/mod.rs create mode 100644 web/src/components/sidebar/nav_item.rs create mode 100644 web/src/components/sidebar/profiling/controls.rs create mode 100644 web/src/components/sidebar/profiling/mod.rs create mode 100644 web/src/components/sidebar/resize.rs create mode 100644 web/src/state/mod.rs create mode 100644 web/src/state/profiling.rs create mode 100644 web/src/state/sidebar.rs delete mode 100644 web/src/styles.rs create mode 100644 web/src/utils/tracing_viewer.rs diff --git a/web/assets/tailwind.css b/web/assets/tailwind.css index 19baf8b9..15cb3e2f 100644 --- a/web/assets/tailwind.css +++ b/web/assets/tailwind.css @@ -24,26 +24,3 @@ .table-container { box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); } - -/* Dark mode support */ -@media (prefers-color-scheme: dark) { - .page-layout { - background-color: #111827; - color: #f9fafb; - } - - .header-bar { - background-color: #1f2937; - border-bottom-color: #374151; - } - - .panel { - background-color: #1f2937; - border-color: #374151; - } - - .table-container { - background-color: #1f2937; - border-color: #374151; - } -} diff --git a/web/src/api/trace.rs b/web/src/api/trace.rs index 8c3a0d19..6bc89ae7 100644 --- a/web/src/api/trace.rs +++ b/web/src/api/trace.rs @@ -14,12 +14,14 @@ pub struct TraceResponse { } /// Trace status information (simplified version, as show_trace only returns function name list) +#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TraceInfo { pub function: String, } /// Variable change record +#[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VariableRecord { pub function_name: String, @@ -44,6 +46,7 @@ pub struct TraceableItem { /// Trace API impl ApiClient { /// Get list of traceable functions (returns function name list, compatible with old format) + #[allow(dead_code)] pub async fn get_traceable_functions(&self, prefix: Option<&str>) -> Result> { let items = self.get_traceable_items(prefix).await?; // Convert to old format for backward compatibility diff --git a/web/src/api/traces.rs b/web/src/api/traces.rs index 3fc57ca9..1a1ae04e 100644 --- a/web/src/api/traces.rs +++ b/web/src/api/traces.rs @@ -431,7 +431,7 @@ impl ApiClient { } else { // No matching span_start found (may have been filtered by limit) // Use unified pid to ensure all spans are in the same process - let mut chrome_event = serde_json::json!({ + let chrome_event = serde_json::json!({ "name": if event.name.is_empty() { "unknown_span" } else { &event.name }, "cat": "span", "ph": "E", @@ -479,6 +479,7 @@ impl ApiClient { } /// Get Ray task execution timeline + #[allow(dead_code)] pub async fn get_ray_timeline( &self, task_filter: Option<&str>, @@ -557,6 +558,7 @@ impl ApiClient { } } +#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RayTimelineEntry { pub name: String, diff --git a/web/src/app.rs b/web/src/app.rs index 11f35923..d22821bb 100644 --- a/web/src/app.rs +++ b/web/src/app.rs @@ -1,3 +1,8 @@ +//! 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}; @@ -7,6 +12,7 @@ use crate::pages::{ profiling::Profiling, python::Python, stack::Stack, traces::Traces, }; +/// All routes. Each is rendered inside AppLayout by the corresponding page component below. #[derive(Routable, Clone, PartialEq)] #[rustfmt::skip] pub enum Route { @@ -28,6 +34,8 @@ pub enum Route { ChromeTracingPage {}, } +// --- Page route components: each wraps a page in AppLayout --- + #[component] pub fn DashboardPage() -> Element { rsx! { AppLayout { Dashboard {} } } @@ -68,22 +76,6 @@ pub fn ChromeTracingPage() -> Element { rsx! { AppLayout { ChromeTracing {} } } } -// Global state: Profiling view type -pub static PROFILING_VIEW: GlobalSignal = Signal::global(|| "pprof".to_string()); - -// Profiling control state -pub static PROFILING_PPROF_FREQ: GlobalSignal = Signal::global(|| 99); -pub static PROFILING_TORCH_ENABLED: GlobalSignal = Signal::global(|| false); -pub static PROFILING_CHROME_DATA_SOURCE: GlobalSignal = Signal::global(|| "trace".to_string()); -pub static PROFILING_CHROME_LIMIT: GlobalSignal = Signal::global(|| 1000); -pub static PROFILING_PYTORCH_STEPS: GlobalSignal = Signal::global(|| 5); -pub static PROFILING_PYTORCH_TIMELINE_RELOAD: GlobalSignal = Signal::global(|| 0); -pub static PROFILING_RAY_TIMELINE_RELOAD: GlobalSignal = Signal::global(|| 0); - -// Sidebar state -pub static SIDEBAR_WIDTH: GlobalSignal = Signal::global(|| 256.0); -pub static SIDEBAR_HIDDEN: GlobalSignal = Signal::global(|| false); - #[component] pub fn App() -> Element { rsx! { diff --git a/web/src/components/card.rs b/web/src/components/card.rs index bf56026e..27ecd25f 100644 --- a/web/src/components/card.rs +++ b/web/src/components/card.rs @@ -1,5 +1,8 @@ +//! White card with title bar and optional header actions (e.g. filters, mode toggles). + use dioxus::prelude::*; +/// Card with a title row and optional `header_right` slot for buttons or controls. #[component] pub fn Card( title: &'static str, @@ -7,14 +10,14 @@ pub fn Card( content_class: Option<&'static str>, #[props(optional)] header_right: Option, ) -> Element { - let content_cls = content_class.unwrap_or("p-6"); + let content_cls = content_class.unwrap_or("p-4"); rsx! { div { - class: "bg-white rounded-lg shadow-sm border border-gray-200", + class: "bg-white rounded-lg border border-gray-200", div { - class: "px-6 py-4 border-b border-gray-200", + class: "px-4 py-3 border-b border-gray-200", div { class: "flex items-center justify-between gap-3", - h3 { class: "text-lg font-semibold text-gray-900", "{title}" } + h3 { class: "text-base font-semibold text-gray-900", "{title}" } if let Some(el) = header_right { div { class: "flex items-center gap-2", {el} } } } } diff --git a/web/src/components/chrome_tracing_iframe.rs b/web/src/components/chrome_tracing_iframe.rs new file mode 100644 index 00000000..a9665333 --- /dev/null +++ b/web/src/components/chrome_tracing_iframe.rs @@ -0,0 +1,74 @@ +use dioxus::prelude::*; + +use crate::components::common::{ErrorState, LoadingState}; +use crate::hooks::ApiState; +use crate::utils::tracing_viewer; + +/// Shared Chrome Tracing iframe viewer. Renders loading/error/empty or iframe from trace JSON state. +#[component] +pub fn ChromeTracingIframe( + state: ApiState, + iframe_key: Signal, + #[props(optional)] loading_message: Option, + #[props(optional)] empty_message: Option, + #[props(optional)] empty_title: Option, + #[props(optional)] error_title: Option, +) -> Element { + let loading_msg = loading_message.as_deref().unwrap_or("Loading timeline..."); + let empty_msg = empty_message.as_deref().unwrap_or("Timeline data is empty."); + let empty_ttl = empty_title.as_deref().unwrap_or("Empty Timeline Data"); + let err_ttl = error_title.as_deref().unwrap_or("Load Timeline Error"); + + if state.is_loading() { + return rsx! { + LoadingState { message: Some(loading_msg.to_string()) } + }; + } + + if let Some(Ok(ref trace_json)) = state.data.read().as_ref() { + if trace_json.trim().is_empty() { + return rsx! { + ErrorState { + error: empty_msg.to_string(), + title: Some(empty_ttl.to_string()) + } + }; + } + if let Err(e) = serde_json::from_str::(trace_json) { + return rsx! { + ErrorState { + error: format!("Invalid JSON: {:?}", e), + title: Some("Invalid Timeline Data".to_string()) + } + }; + } + return rsx! { + div { + class: "absolute inset-0 overflow-hidden", + style: "min-height: 600px;", + iframe { + key: "{*iframe_key.read()}", + srcdoc: tracing_viewer::get_tracing_viewer_html(trace_json), + style: "width: 100%; height: 100%; border: none;", + title: "Chrome Tracing Viewer" + } + } + }; + } + + if let Some(Err(ref err)) = state.data.read().as_ref() { + return rsx! { + ErrorState { + error: format!("Failed to load timeline: {:?}", err), + title: Some(err_ttl.to_string()) + } + }; + } + + rsx! { + div { + class: "absolute inset-0 flex items-center justify-center p-8", + div { class: "text-center text-gray-500", "No data" } + } + } +} diff --git a/web/src/components/collapsible_card.rs b/web/src/components/collapsible_card.rs index 0063799e..088ca375 100644 --- a/web/src/components/collapsible_card.rs +++ b/web/src/components/collapsible_card.rs @@ -1,14 +1,16 @@ use dioxus::prelude::*; +use crate::components::colors::colors; + #[component] pub fn CollapsibleCardWithIcon(title: String, icon: Element, children: Element) -> Element { let mut is_open = use_signal(|| false); rsx! { div { - class: "border border-gray-200 rounded-lg mb-2", + class: "border border-gray-200 rounded-lg mb-2 bg-white", div { - class: "px-4 py-3 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors", + 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; diff --git a/web/src/components/colors.rs b/web/src/components/colors.rs index c60bab9c..738902bd 100644 --- a/web/src/components/colors.rs +++ b/web/src/components/colors.rs @@ -6,13 +6,19 @@ // - Main content area: Light gray/indigo background (clear, readable) // - Accent color: blue (consistent with sidebar, maintains visual unity) +#[allow(dead_code)] pub mod colors { pub const PRIMARY: &str = "blue-600"; + pub const PRIMARY_HOVER: &str = "blue-700"; pub const PRIMARY_BG: &str = "blue-600/30"; pub const PRIMARY_TEXT: &str = "blue-100"; pub const PRIMARY_TEXT_DARK: &str = "blue-400"; pub const PRIMARY_BORDER: &str = "blue-500"; + /// Secondary button (inactive outline) + pub const BTN_SECONDARY_BG: &str = "gray-100"; + pub const BTN_SECONDARY_HOVER: &str = "gray-200"; + pub const SIDEBAR_BG: &str = "slate-900"; pub const SIDEBAR_BG_VIA: &str = "slate-800"; pub const SIDEBAR_BORDER: &str = "slate-700/30"; @@ -33,15 +39,22 @@ pub mod colors { pub const CONTENT_TEXT_MUTED: &str = "gray-500"; pub const SUCCESS: &str = "green-600"; + pub const SUCCESS_HOVER: &str = "green-700"; pub const SUCCESS_LIGHT: &str = "green-50"; pub const SUCCESS_TEXT: &str = "green-800"; pub const SUCCESS_BORDER: &str = "green-200"; pub const ERROR: &str = "red-600"; + pub const ERROR_HOVER: &str = "red-700"; pub const ERROR_LIGHT: &str = "red-50"; pub const ERROR_TEXT: &str = "red-800"; pub const ERROR_BORDER: &str = "red-200"; + /// Content-area accent (e.g. badges, tags on light background) + pub const CONTENT_ACCENT_BG: &str = "blue-50"; + pub const CONTENT_ACCENT_TEXT: &str = "blue-700"; + pub const CONTENT_ACCENT_BORDER: &str = "blue-200"; + pub const WARNING: &str = "yellow-600"; pub const WARNING_LIGHT: &str = "yellow-50"; pub const WARNING_TEXT: &str = "yellow-800"; diff --git a/web/src/components/common.rs b/web/src/components/common.rs index 56adf8b2..328e92a5 100644 --- a/web/src/components/common.rs +++ b/web/src/components/common.rs @@ -1,32 +1,51 @@ +//! Shared UI for async and empty states: loading spinner, error block, empty message. + use dioxus::prelude::*; +use crate::components::colors::colors; + +/// Centered spinner and optional message. Use while data is loading. #[component] pub fn LoadingState(message: Option) -> Element { rsx! { div { - class: "text-center py-8 text-gray-500", - if let Some(msg) = message { - "{msg}" - } else { - "Loading..." + class: "flex flex-col items-center justify-center py-12 gap-3", + div { + class: "w-8 h-8 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin", + } + div { + class: "text-sm text-gray-500", + if let Some(msg) = message { + "{msg}" + } else { + "Loading..." + } } } } } +/// Error block with optional title. Use when a request or operation fails. #[component] pub fn ErrorState(error: String, title: Option) -> Element { + let class_str = format!( + "p-4 rounded border text-{} bg-{} border-{}", + colors::ERROR_TEXT, + colors::ERROR_LIGHT, + colors::ERROR_BORDER + ); rsx! { div { - class: "text-red-500 p-4 bg-red-50 border border-red-200 rounded", + class: "{class_str}", if let Some(title) = title { h3 { class: "font-semibold mb-2", "{title}" } } - "{error}" + pre { class: "text-sm whitespace-pre-wrap break-words", "{error}" } } } } +/// Centered message when there is no data to show. #[component] pub fn EmptyState(message: String) -> Element { rsx! { diff --git a/web/src/components/data.rs b/web/src/components/data.rs index 81bae514..a06c11f2 100644 --- a/web/src/components/data.rs +++ b/web/src/components/data.rs @@ -10,7 +10,7 @@ pub fn KeyValueList(items: Vec<(&'static str, String)>) -> Element { div { class: "flex justify-between items-center py-2 border-b border-gray-200 last:border-b-0", span { class: "font-medium text-gray-700", "{label}" } - span { class: "font-mono text-sm bg-gray-100 px-2 py-1 rounded break-all", "{value}" } + span { class: "font-mono text-sm bg-gray-100 text-gray-900 px-2 py-1 rounded break-all", "{value}" } } } } diff --git a/web/src/components/layout.rs b/web/src/components/layout.rs index 9d3dd790..c2e0bd63 100644 --- a/web/src/components/layout.rs +++ b/web/src/components/layout.rs @@ -1,8 +1,14 @@ +//! 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. + use dioxus::prelude::*; -use crate::components::sidebar::Sidebar; use crate::components::icon::Icon; -use crate::app::{SIDEBAR_WIDTH, SIDEBAR_HIDDEN}; +use crate::components::sidebar::Sidebar; +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"; #[component] pub fn AppLayout(children: Element) -> Element { @@ -11,21 +17,17 @@ pub fn AppLayout(children: Element) -> Element { rsx! { div { - class: "flex h-screen bg-gradient-to-br from-gray-50 to-indigo-50/30 overflow-hidden", + class: "flex h-screen bg-gray-50 overflow-hidden", if !*sidebar_hidden { Sidebar {} } else { button { - class: "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", + class: SHOW_SIDEBAR_BUTTON_CLASS, title: "Show Sidebar", + aria_label: "Show sidebar", onclick: move |_| { *SIDEBAR_HIDDEN.write() = false; - if let Some(window) = web_sys::window() { - let storage = window.local_storage().ok().flatten(); - if let Some(storage) = storage { - let _ = storage.set_item("sidebar_hidden", "false"); - } - } + save_sidebar_state(); }, Icon { icon: &icondata::AiMenuUnfoldOutlined, @@ -34,13 +36,12 @@ pub fn AppLayout(children: Element) -> Element { } } main { - class: "flex-1 overflow-y-auto p-6", - style: if *sidebar_hidden { - "width: 100%;" - } else { - "" - }, - {children} + class: "flex-1 overflow-y-auto p-4 sm:p-6 bg-gray-50", + style: if *sidebar_hidden { "width: 100%;" } else { "" }, + div { + class: "max-w-7xl mx-auto w-full", + {children} + } } } } diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index d8f7205b..c1fbcc84 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -1,6 +1,21 @@ +//! 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. +//! - **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. +//! - **chrome_tracing_iframe** — Chrome Tracing viewer wrapper. + pub mod card; pub mod card_view; pub mod callstack_view; +pub mod chrome_tracing_iframe; pub mod collapsible_card; pub mod colors; pub mod common; diff --git a/web/src/components/page.rs b/web/src/components/page.rs index 1ea82094..82ebd78c 100644 --- a/web/src/components/page.rs +++ b/web/src/components/page.rs @@ -1,7 +1,12 @@ +//! 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, @@ -10,20 +15,20 @@ pub fn PageTitle( ) -> Element { rsx! { div { - class: "mb-6", + class: "mb-4", div { - class: "flex items-center gap-3 mb-2", + class: "flex items-center gap-2", if let Some(icon_data) = icon { - Icon { icon: icon_data, class: "w-6 h-6 text-indigo-600" } + Icon { icon: icon_data, class: "w-5 h-5 text-blue-600" } } h1 { - class: "text-2xl font-bold text-gray-900", + class: "text-xl font-semibold text-gray-900", "{title}" } } if let Some(subtitle) = subtitle { p { - class: "text-sm text-gray-600 ml-9", + class: "text-sm text-gray-500 mt-0.5", "{subtitle}" } } @@ -31,12 +36,12 @@ pub fn PageTitle( } } - +/// 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-6", + class: "space-y-4", {children} } } diff --git a/web/src/components/sidebar.rs b/web/src/components/sidebar.rs deleted file mode 100644 index 5fa1ab20..00000000 --- a/web/src/components/sidebar.rs +++ /dev/null @@ -1,731 +0,0 @@ -use dioxus::prelude::*; -use dioxus_router::{Link, use_route, use_navigator}; -use icondata::Icon as IconData; -use web_sys::window; - -use crate::app::{Route, PROFILING_VIEW, PROFILING_PPROF_FREQ, PROFILING_TORCH_ENABLED, - PROFILING_CHROME_DATA_SOURCE, PROFILING_CHROME_LIMIT, PROFILING_PYTORCH_STEPS, - PROFILING_PYTORCH_TIMELINE_RELOAD, PROFILING_RAY_TIMELINE_RELOAD, SIDEBAR_WIDTH, SIDEBAR_HIDDEN}; -use crate::components::icon::Icon; -use crate::components::colors::colors; -use crate::api::{ApiClient, ProfileResponse}; -use crate::hooks::use_api_simple; - -#[component] -pub fn Sidebar() -> Element { - let route = use_route::(); - let mut show_profiling_dropdown = use_signal(|| false); - 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); - - use_effect(move || { - if let Some(window) = window() { - let storage = window.local_storage().ok().flatten(); - if let Some(storage) = storage { - if let Ok(Some(width_str)) = storage.get_item("sidebar_width") { - if let Ok(width) = width_str.parse::() { - if width >= 200.0 && width <= 600.0 { - *SIDEBAR_WIDTH.write() = width; - } - } - } - if let Ok(Some(hidden_str)) = storage.get_item("sidebar_hidden") { - if hidden_str == "true" { - *SIDEBAR_HIDDEN.write() = true; - } - } - } - } - }); - - let save_state = move || { - if let Some(window) = window() { - let storage = window.local_storage().ok().flatten(); - if let Some(storage) = storage { - let _ = storage.set_item("sidebar_width", &SIDEBAR_WIDTH.read().to_string()); - let _ = storage.set_item("sidebar_hidden", &SIDEBAR_HIDDEN.read().to_string()); - } - } - }; - - let sidebar_width = SIDEBAR_WIDTH.read(); - let _sidebar_hidden = SIDEBAR_HIDDEN.read(); - - let aside_class = format!("bg-gradient-to-b from-{} via-{} to-{} border-r border-{} h-screen flex flex-col flex-shrink-0 shadow-xl", - colors::SIDEBAR_BG, colors::SIDEBAR_BG_VIA, colors::SIDEBAR_BG, colors::SIDEBAR_BORDER); - let logo_border_class = format!("px-6 py-4 border-b border-{}", colors::SIDEBAR_BORDER); - let brand_title_class = format!("text-lg font-bold text-{}", colors::SIDEBAR_TEXT_PRIMARY); - let brand_subtitle_class = format!("text-xs text-{}", colors::SIDEBAR_TEXT_MUTED); - let section_title_class = format!("px-3 py-2 text-xs font-semibold text-{} uppercase tracking-wider", colors::SIDEBAR_TEXT_MUTED); - let footer_class = format!("px-6 py-4 border-t border-{}", colors::SIDEBAR_BORDER); - let footer_link_class = format!("flex items-center space-x-2 text-sm text-{} hover:text-{} transition-colors", - colors::SIDEBAR_TEXT_MUTED, colors::PRIMARY_TEXT_DARK); - let hide_button_class = format!("absolute top-4 -right-3 w-6 h-6 bg-{} border border-{} rounded-full shadow-lg flex items-center justify-center hover:bg-{} z-30 transition-colors", - colors::SIDEBAR_ACTIVE_BG, "slate-700", "slate-600"); - - rsx! { - div { - class: "relative flex h-screen", - style: format!("width: {}px;", *sidebar_width), - aside { - class: "{aside_class}", - style: format!("width: {}px;", *sidebar_width), - div { - class: "{logo_border_class}", - Link { - to: Route::DashboardPage {}, - class: "flex items-center space-x-3", - img { - src: "/assets/logo.svg", - alt: "Probing Logo", - class: "w-8 h-8 flex-shrink-0", - } - div { - class: "flex flex-col", - span { - class: "{brand_title_class}", - "Probing" - } - span { - class: "{brand_subtitle_class}", - "Performance Profiler" - } - } - } - } - - nav { - class: "flex-1 overflow-y-auto py-4", - div { - class: "px-3 space-y-1", - div { - class: "mb-4", - div { - class: "{section_title_class}", - "Overview" - } - SidebarNavItem { - to: Route::DashboardPage {}, - icon: &icondata::AiLineChartOutlined, - label: "Dashboard", - is_active: route == Route::DashboardPage {}, - } - } - - div { - class: "mb-4", - div { - class: "{section_title_class}", - "Analysis" - } - SidebarNavItem { - to: Route::StackPage {}, - icon: &icondata::AiThunderboltOutlined, - label: "Stacks", - is_active: route == Route::StackPage {}, - } - ProfilingSidebarItem { - show_dropdown: show_profiling_dropdown, - } - SidebarNavItem { - to: Route::AnalyticsPage {}, - icon: &icondata::AiAreaChartOutlined, - label: "Analytics", - is_active: route == Route::AnalyticsPage {}, - } - SidebarNavItem { - to: Route::TracesPage {}, - icon: &icondata::AiApiOutlined, - label: "Traces", - is_active: route == Route::TracesPage {}, - } - } - - div { - class: "mb-4", - div { - class: "{section_title_class}", - "System" - } - SidebarNavItem { - to: Route::ClusterPage {}, - icon: &icondata::AiClusterOutlined, - label: "Cluster", - is_active: route == Route::ClusterPage {}, - } - SidebarNavItem { - to: Route::PythonPage {}, - icon: &icondata::SiPython, - label: "Python", - is_active: route == Route::PythonPage {}, - } - } - } - } - - div { - class: "{footer_class}", - a { - href: "https://github.com/reiase/probing", - target: "_blank", - class: "{footer_link_class}", - Icon { icon: &icondata::AiGithubOutlined, class: "w-4 h-4" } - span { "GitHub" } - } - } - } - - button { - class: "{hide_button_class}", - title: "Hide Sidebar", - onclick: move |_| { - *SIDEBAR_HIDDEN.write() = true; - save_state(); - }, - Icon { - icon: &icondata::AiMenuFoldOutlined, - class: "w-4 h-4 text-slate-300" - } - } - - { - let hover_class = format!("hover:bg-{}/50", colors::PRIMARY); - let active_class = if *is_resizing.read() { - format!("bg-{}", colors::PRIMARY) - } else { - "bg-transparent".to_string() - }; - let drag_handle_class = format!("absolute top-0 right-0 w-1 h-full cursor-col-resize {} transition-colors group z-20 {}", hover_class, active_class); - rsx! { - div { - class: "{drag_handle_class}", - onmousedown: move |ev| { - *is_resizing.write() = true; - *drag_start_x.write() = ev.element_coordinates().x as f64; - *drag_start_width.write() = *SIDEBAR_WIDTH.read(); - ev.prevent_default(); - }, - onmousemove: move |ev| { - if *is_resizing.read() { - let current_x = ev.element_coordinates().x as f64; - let delta_x = current_x - *drag_start_x.read(); - let new_width = (*drag_start_width.read() + delta_x).max(200.0).min(600.0); - *SIDEBAR_WIDTH.write() = new_width; - } - }, - onmouseup: move |_| { - if *is_resizing.read() { - *is_resizing.write() = false; - if let Some(window) = window() { - let storage = window.local_storage().ok().flatten(); - if let Some(storage) = storage { - let _ = storage.set_item("sidebar_width", &SIDEBAR_WIDTH.read().to_string()); - } - } - } - }, - 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", - } - } - } - } - } - } -} - -#[component] -fn SidebarNavItem(to: Route, icon: &'static IconData, label: &'static str, is_active: bool) -> Element { - let class_str = if is_active { - format!("flex items-center space-x-3 px-3 py-2 text-sm font-medium rounded-md bg-{} text-{} border-l-2 border-{} shadow-sm", - colors::PRIMARY_BG, colors::PRIMARY_TEXT, colors::PRIMARY_BORDER) - } else { - format!("flex items-center space-x-3 px-3 py-2 text-sm font-medium rounded-md text-{} hover:bg-{} hover:text-{} transition-colors", - colors::SIDEBAR_TEXT_SECONDARY, colors::SIDEBAR_HOVER_BG, colors::PRIMARY_TEXT) - }; - - rsx! { - Link { - to: to, - class: "{class_str}", - Icon { icon, class: "w-5 h-5" } - span { "{label}" } - } - } -} - -#[component] -fn ProfilingSidebarItem(show_dropdown: Signal) -> Element { - let route = use_route::(); - let is_active = route == Route::ProfilingPage {}; - - rsx! { - div { - { - let button_class = if is_active { - format!("w-full flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors bg-{} text-{} border-l-2 border-{} shadow-sm", - colors::PRIMARY_BG, colors::PRIMARY_TEXT, colors::PRIMARY_BORDER) - } else { - format!("w-full flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors text-{} hover:bg-{} hover:text-{}", - colors::SIDEBAR_TEXT_SECONDARY, colors::SIDEBAR_HOVER_BG, colors::PRIMARY_TEXT) - }; - rsx! { - button { - class: "{button_class}", - onclick: { - let mut show_dropdown = show_dropdown.clone(); - move |_| { - let current = *show_dropdown.read(); - *show_dropdown.write() = !current; - } - }, - div { - class: "flex items-center space-x-3", - Icon { icon: &icondata::AiSearchOutlined, class: "w-5 h-5" } - span { "Profiling" } - } - } - } - } - - if *show_dropdown.read() { - div { - class: "ml-6 mt-1 space-y-1", - ProfilingSubItem { - view: "pprof".to_string(), - label: "pprof Flamegraph".to_string(), - icon: &icondata::CgPerformance, - } - ProfilingSubItem { - view: "torch".to_string(), - label: "torch Flamegraph".to_string(), - icon: &icondata::SiPytorch, - } - ProfilingSubItem { - view: "trace-timeline".to_string(), - label: "Trace Timeline".to_string(), - icon: &icondata::AiThunderboltOutlined, - } - ProfilingSubItem { - view: "pytorch-timeline".to_string(), - label: "PyTorch Timeline".to_string(), - icon: &icondata::SiPytorch, - } - ProfilingSubItem { - view: "ray-timeline".to_string(), - label: "Ray Timeline".to_string(), - icon: &icondata::AiClockCircleOutlined, - } - - if is_active { - ProfilingControlsPanel {} - } - } - } - } - } -} - -#[component] -fn ProfilingSubItem(view: String, label: String, icon: &'static IconData) -> Element { - let route = use_route::(); - let navigator = use_navigator(); - let current_view = PROFILING_VIEW.read(); - let is_selected = *current_view == view; - let is_on_profiling_page = route == Route::ProfilingPage {}; - - let button_class = if is_selected { - format!("w-full flex items-center space-x-2 px-3 py-2 text-sm rounded-md transition-colors bg-{} text-{} font-medium border-l-2 border-{} shadow-sm", - colors::PRIMARY_BG, colors::PRIMARY_TEXT, colors::PRIMARY_BORDER) - } else { - format!("w-full flex items-center space-x-2 px-3 py-2 text-sm rounded-md transition-colors text-{} hover:bg-{} hover:text-{}", - colors::SIDEBAR_TEXT_SECONDARY, colors::SIDEBAR_HOVER_BG, colors::PRIMARY_TEXT) - }; - - rsx! { - button { - class: "{button_class}", - onclick: { - let view_clone = view.clone(); - let navigator = navigator.clone(); - let is_on_profiling = is_on_profiling_page; - move |_| { - *PROFILING_VIEW.write() = view_clone.clone(); - if !is_on_profiling { - navigator.push(Route::ProfilingPage {}); - } - } - }, - Icon { icon, class: "w-4 h-4" } - span { "{label}" } - if is_selected { - { - let checkmark_class = format!("ml-auto text-{} font-semibold", colors::PRIMARY_TEXT_DARK); - rsx! { - span { class: "{checkmark_class}", "✓" } - } - } - } - } - } -} - -#[component] -fn ProfilingControlsPanel() -> Element { - let current_view = PROFILING_VIEW.read(); - let panel_border_class = format!("mt-4 pt-4 border-t border-{}", colors::SIDEBAR_BORDER); - let control_title_class = format!("text-xs font-semibold text-{}", colors::SIDEBAR_TEXT_SECONDARY); - let control_value_class = format!("text-xs text-{}", colors::SIDEBAR_TEXT_MUTED); - let toggle_enabled_class = format!("relative inline-flex h-6 w-11 items-center rounded-full transition-colors w-full bg-{}", colors::PRIMARY); - let toggle_disabled_class = format!("relative inline-flex h-6 w-11 items-center rounded-full transition-colors w-full bg-{}", colors::SIDEBAR_ACTIVE_BG); - let toggle_label_class = format!("ml-2 text-xs text-{}", colors::SIDEBAR_TEXT_SECONDARY); - let button_active_class = format!("flex-1 px-2 py-1 text-xs font-medium rounded bg-{} text-white shadow-sm", colors::PRIMARY); - let button_inactive_class = format!("flex-1 px-2 py-1 text-xs font-medium rounded bg-{} text-{} hover:bg-{}", colors::SIDEBAR_ACTIVE_BG, colors::SIDEBAR_TEXT_SECONDARY, "slate-600"); - let input_class = format!("w-full px-2 py-1 border border-{} bg-{} text-{} rounded text-xs focus:border-{} focus:outline-none", - colors::SIDEBAR_INPUT_BORDER, colors::SIDEBAR_INPUT_BG, colors::SIDEBAR_TEXT_SECONDARY, colors::PRIMARY_BORDER); - - rsx! { - div { - class: "{panel_border_class}", - div { - class: "px-3 space-y-4", - { - let view = (*current_view).clone(); - if view == "pprof" { - rsx! { - PprofControls { - control_title_class: control_title_class.clone(), - control_value_class: control_value_class.clone(), - } - } - } else if view == "torch" { - rsx! { - TorchControls { - control_title_class: control_title_class.clone(), - toggle_enabled_class: toggle_enabled_class.clone(), - toggle_disabled_class: toggle_disabled_class.clone(), - toggle_label_class: toggle_label_class.clone(), - } - } - } else if view == "trace-timeline" { - rsx! { - TraceTimelineControls { - control_title_class: control_title_class.clone(), - control_value_class: control_value_class.clone(), - input_class: input_class.clone(), - } - } - } else if view == "pytorch-timeline" { - rsx! { - PyTorchTimelineControls { - control_title_class: control_title_class.clone(), - input_class: input_class.clone(), - } - } - } else if view == "ray-timeline" { - rsx! { - RayTimelineControls { - control_title_class: control_title_class.clone(), - input_class: input_class.clone(), - } - } - } else { - rsx! { div {} } - } - } - } - } - } -} - -#[component] -fn PprofControls(control_title_class: String, control_value_class: String) -> Element { - const FREQ_VALUES: [i32; 4] = [0, 10, 100, 1000]; - - let freq = *PROFILING_PPROF_FREQ.read(); - let current_idx = match freq { - f if f <= 0 => 0, - f if f <= 10 => 1, - f if f <= 100 => 2, - _ => 3, - }; - let label = FREQ_VALUES[current_idx]; - - rsx! { - div { - class: "space-y-2", - div { - class: "{control_title_class}", - "Pprof Frequency" - } - div { - class: "space-y-1", - div { - class: "{control_value_class} flex items-center justify-between", - span { "{label} Hz" } - } - input { - r#type: "range", - min: "0", - max: "3", - step: "1", - value: "{current_idx}", - class: "w-full", - oninput: move |ev| { - if let Ok(idx) = ev.value().parse::() { - if idx < FREQ_VALUES.len() { - let mapped = FREQ_VALUES[idx]; - *PROFILING_PPROF_FREQ.write() = mapped; - spawn(async move { - let client = ApiClient::new(); - let expr = if mapped <= 0 { - "set probing.pprof.sample_freq=;".to_string() - } else { - format!("set probing.pprof.sample_freq={};", mapped) - }; - let _ = client.execute_query(&expr).await; - }); - } - } - } - } - } - } - } -} - -#[component] -fn TorchControls( - control_title_class: String, - toggle_enabled_class: String, - toggle_disabled_class: String, - toggle_label_class: String, -) -> Element { - let is_enabled = *PROFILING_TORCH_ENABLED.read(); - let toggle_class = if is_enabled { - toggle_enabled_class.clone() - } else { - toggle_disabled_class.clone() - }; - - rsx! { - div { - class: "space-y-2", - div { - class: "{control_title_class}", - "Torch Profiling" - } - button { - class: "{toggle_class}", - onclick: move |_| { - let enabled = !*PROFILING_TORCH_ENABLED.read(); - spawn(async move { - let client = ApiClient::new(); - let expr = if enabled { - "set probing.torch.profiling=on;".to_string() - } else { - "set probing.torch.profiling=;".to_string() - }; - let _ = client.execute_query(&expr).await; - *PROFILING_TORCH_ENABLED.write() = enabled; - }); - }, - span { - class: "inline-block h-4 w-4 transform rounded-full bg-white transition-transform", - class: if *PROFILING_TORCH_ENABLED.read() { - "translate-x-6" - } else { - "translate-x-1" - } - } - span { - class: "{toggle_label_class}", - if *PROFILING_TORCH_ENABLED.read() { - "Enabled" - } else { - "Disabled" - } - } - } - } - } -} - -#[component] -fn TraceTimelineControls( - control_title_class: String, - control_value_class: String, - input_class: String, -) -> Element { - rsx! { - div { - class: "space-y-3", - div { - class: "space-y-1", - div { - class: "{control_title_class}", - "Event Limit" - } - div { - class: "flex items-center gap-2", - span { - class: "{control_value_class}", - "{*PROFILING_CHROME_LIMIT.read()}" - } - input { - r#type: "range", - min: "100", - max: "5000", - step: "100", - value: "{*PROFILING_CHROME_LIMIT.read()}", - class: "flex-1", - oninput: move |ev| { - if let Ok(val) = ev.value().parse::() { - *PROFILING_CHROME_LIMIT.write() = val; - } - } - } - } - } - } - } -} - -#[component] -fn RayTimelineControls( - control_title_class: String, - input_class: String, -) -> Element { - rsx! { - div { - class: "space-y-3", - div { - class: "space-y-2", - div { - class: "{control_title_class}", - "Ray Timeline Controls" - } - button { - class: "w-full px-3 py-2 text-xs font-medium rounded bg-blue-600 text-white hover:bg-blue-700", - onclick: move |_| { - *PROFILING_RAY_TIMELINE_RELOAD.write() += 1; - }, - "Reload Ray Timeline" - } - } - } - } -} - -#[component] -fn PyTorchTimelineControls( - control_title_class: String, - input_class: String, -) -> Element { - let pytorch_profile_state = use_api_simple::(); - let pytorch_timeline_state = use_api_simple::(); - - rsx! { - div { - class: "space-y-3", - div { - class: "space-y-2", - div { - class: "{control_title_class}", - "Steps" - } - input { - r#type: "number", - min: "1", - max: "100", - value: "{*PROFILING_PYTORCH_STEPS.read()}", - class: "{input_class}", - oninput: move |ev| { - if let Ok(val) = ev.value().parse::() { - *PROFILING_PYTORCH_STEPS.write() = val.max(1).min(100); - } - } - } - } - div { - class: "space-y-2", - button { - class: "w-full px-3 py-2 text-xs font-medium rounded bg-green-600 text-white hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed", - disabled: pytorch_profile_state.is_loading(), - onclick: { - let mut profile_state = pytorch_profile_state.clone(); - move |_| { - spawn(async move { - *profile_state.loading.write() = true; - let client = ApiClient::new(); - let steps = *PROFILING_PYTORCH_STEPS.read(); - let result = client.start_pytorch_profile(steps).await; - *profile_state.data.write() = Some(result); - *profile_state.loading.write() = false; - }); - } - }, - if pytorch_profile_state.is_loading() { - "Starting..." - } else { - "Start Profile" - } - } - button { - class: "w-full px-3 py-2 text-xs font-medium rounded bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed", - disabled: pytorch_timeline_state.is_loading(), - onclick: { - let mut timeline_state = pytorch_timeline_state.clone(); - move |_| { - spawn(async move { - *timeline_state.loading.write() = true; - *timeline_state.data.write() = None; - let client = ApiClient::new(); - let result = client.get_pytorch_timeline().await; - let is_ok = result.is_ok(); - *timeline_state.data.write() = Some(result); - *timeline_state.loading.write() = false; - // Trigger timeline reload - if is_ok { - *PROFILING_PYTORCH_TIMELINE_RELOAD.write() += 1; - } - }); - } - }, - if pytorch_timeline_state.is_loading() { - "Loading..." - } else { - "Load Timeline" - } - } - } - if let Some(Ok(ref profile_result)) = pytorch_profile_state.data.read().as_ref() { - if profile_result.success { - div { - class: "p-1.5 bg-green-50 border border-green-200 rounded text-xs text-green-800", - if let Some(ref msg) = profile_result.message { - "{msg}" - } else { - "Profile started" - } - } - } else { - div { - class: "p-1.5 bg-red-50 border border-red-200 rounded text-xs text-red-800", - if let Some(ref err) = profile_result.error { - "{err}" - } else { - "Failed to start" - } - } - } - } - } - } -} diff --git a/web/src/components/sidebar/mod.rs b/web/src/components/sidebar/mod.rs new file mode 100644 index 00000000..890e7c41 --- /dev/null +++ b/web/src/components/sidebar/mod.rs @@ -0,0 +1,148 @@ +//! 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::{Link, use_route}; + +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 nav_item; +mod profiling; +mod resize; + +use nav_item::SidebarNavItem; +use profiling::ProfilingSidebarItem; +use resize::ResizeHandle; + +fn sidebar_classes() -> (String, String, String, String, String, String) { + ( + format!( + "bg-gradient-to-b from-{} via-{} to-{} border-r border-{} h-screen flex flex-col flex-shrink-0 shadow-xl", + colors::SIDEBAR_BG, + colors::SIDEBAR_BG_VIA, + colors::SIDEBAR_BG, + colors::SIDEBAR_BORDER + ), + format!("px-4 py-3 border-b border-{}", colors::SIDEBAR_BORDER), + format!("text-base font-semibold text-{}", colors::SIDEBAR_TEXT_PRIMARY), + format!("px-4 py-3 border-t border-{}", colors::SIDEBAR_BORDER), + format!( + "flex items-center gap-2 text-xs text-{} hover:text-{} transition-colors", + colors::SIDEBAR_TEXT_MUTED, + colors::PRIMARY_TEXT_DARK + ), + format!( + "absolute top-4 -right-3 w-6 h-6 bg-{} border border-slate-700 rounded-full shadow-lg flex items-center justify-center hover:bg-slate-600 z-30 transition-colors", + colors::SIDEBAR_ACTIVE_BG + ), + ) +} + +#[component] +pub fn Sidebar() -> Element { + let route = use_route::(); + let show_profiling_dropdown = use_signal(|| false); + + use_effect(move || { + load_sidebar_state(); + }); + + 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: "/assets/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", + SidebarNavItem { + to: Route::DashboardPage {}, + icon: &icondata::AiLineChartOutlined, + label: "Dashboard", + is_active: route == Route::DashboardPage {}, + } + SidebarNavItem { + to: Route::StackPage {}, + icon: &icondata::AiThunderboltOutlined, + label: "Stacks", + is_active: route == Route::StackPage {}, + } + ProfilingSidebarItem { + show_dropdown: show_profiling_dropdown, + } + SidebarNavItem { + to: Route::AnalyticsPage {}, + icon: &icondata::AiAreaChartOutlined, + label: "Analytics", + is_active: route == Route::AnalyticsPage {}, + } + SidebarNavItem { + to: Route::TracesPage {}, + icon: &icondata::AiApiOutlined, + label: "Traces", + is_active: route == Route::TracesPage {}, + } + div { class: "pt-2" } + SidebarNavItem { + to: Route::ClusterPage {}, + icon: &icondata::AiClusterOutlined, + label: "Cluster", + is_active: route == Route::ClusterPage {}, + } + SidebarNavItem { + to: Route::PythonPage {}, + icon: &icondata::SiPython, + label: "Python", + is_active: route == Route::PythonPage {}, + } + } + } + + 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/nav_item.rs b/web/src/components/sidebar/nav_item.rs new file mode 100644 index 00000000..0555a6df --- /dev/null +++ b/web/src/components/sidebar/nav_item.rs @@ -0,0 +1,45 @@ +//! 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) -> String { + if is_active { + format!( + "flex items-center gap-2 px-2 py-1.5 text-sm font-medium rounded-md bg-{} text-{} border-l-2 border-{}", + colors::PRIMARY_BG, + colors::PRIMARY_TEXT, + colors::PRIMARY_BORDER + ) + } else { + format!( + "flex items-center gap-2 px-2 py-1.5 text-sm font-medium rounded-md text-{} hover:bg-{} hover:text-{} transition-colors", + colors::SIDEBAR_TEXT_SECONDARY, + colors::SIDEBAR_HOVER_BG, + colors::PRIMARY_TEXT + ) + } +} + +#[component] +pub fn SidebarNavItem( + to: Route, + icon: &'static IconData, + label: &'static str, + is_active: bool, +) -> Element { + rsx! { + Link { + to: to, + class: "{sidebar_item_class(is_active)}", + Icon { icon, class: "w-4 h-4" } + span { "{label}" } + } + } +} diff --git a/web/src/components/sidebar/profiling/controls.rs b/web/src/components/sidebar/profiling/controls.rs new file mode 100644 index 00000000..bf4db65d --- /dev/null +++ b/web/src/components/sidebar/profiling/controls.rs @@ -0,0 +1,288 @@ +use dioxus::prelude::*; + +use crate::api::{ApiClient, ProfileResponse}; +use crate::components::colors::colors; +use crate::hooks::use_api_simple; +use crate::state::profiling::{ + PROFILING_CHROME_LIMIT, PROFILING_PPROF_FREQ, PROFILING_PYTORCH_STEPS, + PROFILING_PYTORCH_TIMELINE_RELOAD, PROFILING_RAY_TIMELINE_RELOAD, PROFILING_TORCH_ENABLED, +}; + +#[component] +pub fn PprofControls(control_title_class: String, control_value_class: String) -> Element { + const FREQ_VALUES: [i32; 4] = [0, 10, 100, 1000]; + + let freq = *PROFILING_PPROF_FREQ.read(); + let current_idx = match freq { + f if f <= 0 => 0, + f if f <= 10 => 1, + f if f <= 100 => 2, + _ => 3, + }; + let label = FREQ_VALUES[current_idx]; + + rsx! { + div { + class: "space-y-2", + div { + class: "{control_title_class}", + "Pprof Frequency" + } + div { + class: "space-y-1", + div { + class: "{control_value_class} flex items-center justify-between", + span { "{label} Hz" } + } + input { + r#type: "range", + min: "0", + max: "3", + step: "1", + value: "{current_idx}", + class: "w-full", + oninput: move |ev| { + if let Ok(idx) = ev.value().parse::() { + if idx < FREQ_VALUES.len() { + let mapped = FREQ_VALUES[idx]; + *PROFILING_PPROF_FREQ.write() = mapped; + spawn(async move { + let client = ApiClient::new(); + let expr = if mapped <= 0 { + "set probing.pprof.sample_freq=;".to_string() + } else { + format!("set probing.pprof.sample_freq={};", mapped) + }; + let _ = client.execute_query(&expr).await; + }); + } + } + } + } + } + } + } +} + +#[component] +pub fn TorchControls( + control_title_class: String, + toggle_enabled_class: String, + toggle_disabled_class: String, + toggle_label_class: String, +) -> Element { + let is_enabled = *PROFILING_TORCH_ENABLED.read(); + let toggle_class = if is_enabled { + toggle_enabled_class.clone() + } else { + toggle_disabled_class.clone() + }; + + rsx! { + div { + class: "space-y-2", + div { + class: "{control_title_class}", + "Torch Profiling" + } + button { + class: "{toggle_class}", + onclick: move |_| { + let enabled = !*PROFILING_TORCH_ENABLED.read(); + spawn(async move { + let client = ApiClient::new(); + let expr = if enabled { + "set probing.torch.profiling=on;".to_string() + } else { + "set probing.torch.profiling=;".to_string() + }; + let _ = client.execute_query(&expr).await; + *PROFILING_TORCH_ENABLED.write() = enabled; + }); + }, + span { + class: "inline-block h-4 w-4 transform rounded-full bg-white transition-transform", + class: if *PROFILING_TORCH_ENABLED.read() { + "translate-x-6" + } else { + "translate-x-1" + } + } + span { + class: "{toggle_label_class}", + if *PROFILING_TORCH_ENABLED.read() { + "Enabled" + } else { + "Disabled" + } + } + } + } + } +} + +#[component] +pub fn TraceTimelineControls( + control_title_class: String, + control_value_class: String, + input_class: String, +) -> Element { + rsx! { + div { + class: "space-y-3", + div { + class: "space-y-1", + div { + class: "{control_title_class}", + "Event Limit" + } + div { + class: "flex items-center gap-2", + span { + class: "{control_value_class}", + "{*PROFILING_CHROME_LIMIT.read()}" + } + input { + r#type: "range", + min: "100", + max: "5000", + step: "100", + value: "{*PROFILING_CHROME_LIMIT.read()}", + class: "flex-1", + oninput: move |ev| { + if let Ok(val) = ev.value().parse::() { + *PROFILING_CHROME_LIMIT.write() = val; + } + } + } + } + } + } + } +} + +#[component] +pub fn RayTimelineControls(control_title_class: String, input_class: String) -> Element { + rsx! { + div { + class: "space-y-3", + div { + class: "space-y-2", + div { + class: "{control_title_class}", + "Ray Timeline Controls" + } + button { + class: format!("w-full px-3 py-2 text-xs font-medium rounded bg-{} text-white hover:bg-{}", colors::PRIMARY, colors::PRIMARY_HOVER), + onclick: move |_| { + *PROFILING_RAY_TIMELINE_RELOAD.write() += 1; + }, + "Reload Ray Timeline" + } + } + } + } +} + +#[component] +pub fn PyTorchTimelineControls(control_title_class: String, input_class: String) -> Element { + let pytorch_profile_state = use_api_simple::(); + let pytorch_timeline_state = use_api_simple::(); + + rsx! { + div { + class: "space-y-3", + div { + class: "space-y-2", + div { + class: "{control_title_class}", + "Steps" + } + input { + r#type: "number", + min: "1", + max: "100", + value: "{*PROFILING_PYTORCH_STEPS.read()}", + class: "{input_class}", + oninput: move |ev| { + if let Ok(val) = ev.value().parse::() { + *PROFILING_PYTORCH_STEPS.write() = val.max(1).min(100); + } + } + } + } + div { + class: "space-y-2", + button { + class: format!("w-full px-3 py-2 text-xs font-medium rounded bg-{} text-white hover:bg-{} disabled:bg-gray-400 disabled:cursor-not-allowed", colors::SUCCESS, colors::SUCCESS_HOVER), + disabled: pytorch_profile_state.is_loading(), + onclick: { + let mut profile_state = pytorch_profile_state.clone(); + move |_| { + spawn(async move { + *profile_state.loading.write() = true; + let client = ApiClient::new(); + let steps = *PROFILING_PYTORCH_STEPS.read(); + let result = client.start_pytorch_profile(steps).await; + *profile_state.data.write() = Some(result); + *profile_state.loading.write() = false; + }); + } + }, + if pytorch_profile_state.is_loading() { + "Starting..." + } else { + "Start Profile" + } + } + button { + class: format!("w-full px-3 py-2 text-xs font-medium rounded bg-{} text-white hover:bg-{} disabled:bg-gray-400 disabled:cursor-not-allowed", colors::PRIMARY, colors::PRIMARY_HOVER), + disabled: pytorch_timeline_state.is_loading(), + onclick: { + let mut timeline_state = pytorch_timeline_state.clone(); + move |_| { + spawn(async move { + *timeline_state.loading.write() = true; + *timeline_state.data.write() = None; + let client = ApiClient::new(); + let result = client.get_pytorch_timeline().await; + let is_ok = result.is_ok(); + *timeline_state.data.write() = Some(result); + *timeline_state.loading.write() = false; + if is_ok { + *PROFILING_PYTORCH_TIMELINE_RELOAD.write() += 1; + } + }); + } + }, + if pytorch_timeline_state.is_loading() { + "Loading..." + } else { + "Load Timeline" + } + } + } + if let Some(Ok(ref profile_result)) = pytorch_profile_state.data.read().as_ref() { + if profile_result.success { + div { + class: format!("p-1.5 bg-{} border border-{} rounded text-xs text-{}", colors::SUCCESS_LIGHT, colors::SUCCESS_BORDER, colors::SUCCESS_TEXT), + if let Some(ref msg) = profile_result.message { + "{msg}" + } else { + "Profile started" + } + } + } else { + div { + class: format!("p-1.5 bg-{} border border-{} rounded text-xs text-{}", colors::ERROR_LIGHT, colors::ERROR_BORDER, colors::ERROR_TEXT), + if let Some(ref err) = profile_result.error { + "{err}" + } else { + "Failed to start" + } + } + } + } + } + } +} diff --git a/web/src/components/sidebar/profiling/mod.rs b/web/src/components/sidebar/profiling/mod.rs new file mode 100644 index 00000000..4a7b0cd2 --- /dev/null +++ b/web/src/components/sidebar/profiling/mod.rs @@ -0,0 +1,183 @@ +//! Profiling submenu and view switcher. Uses [nav_item::sidebar_item_class](crate::components::sidebar::nav_item::sidebar_item_class) for style. + +use dioxus::prelude::*; +use dioxus_router::{use_navigator, use_route}; +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::PROFILING_VIEW; + +mod controls; +use controls::{ + PprofControls, PyTorchTimelineControls, RayTimelineControls, TorchControls, + TraceTimelineControls, +}; + +#[component] +pub fn ProfilingSidebarItem(show_dropdown: Signal) -> Element { + let route = use_route::(); + let is_active = route == Route::ProfilingPage {}; + 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: "Profiling menu", + 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", + ProfilingSubItem { + view: "pprof".to_string(), + label: "pprof".to_string(), + icon: &icondata::CgPerformance, + } + ProfilingSubItem { + view: "torch".to_string(), + label: "torch".to_string(), + icon: &icondata::SiPytorch, + } + ProfilingSubItem { + view: "trace-timeline".to_string(), + label: "Trace".to_string(), + icon: &icondata::AiThunderboltOutlined, + } + ProfilingSubItem { + view: "pytorch-timeline".to_string(), + label: "PyTorch".to_string(), + icon: &icondata::SiPytorch, + } + ProfilingSubItem { + view: "ray-timeline".to_string(), + label: "Ray".to_string(), + icon: &icondata::AiClockCircleOutlined, + } + + if is_active { + ProfilingControlsPanel {} + } + } + } + } + } +} + +#[component] +pub fn ProfilingSubItem(view: String, label: String, icon: &'static IconData) -> Element { + let route = use_route::(); + let navigator = use_navigator(); + let is_selected = *PROFILING_VIEW.read() == view; + let is_on_profiling_page = route == Route::ProfilingPage {}; + let button_class = format!("w-full {}", sidebar_item_class(is_selected)); + let check_class = format!("ml-auto text-{} font-semibold", colors::PRIMARY_TEXT_DARK); + + rsx! { + button { + class: "{button_class}", + onclick: { + let v = view.clone(); + let nav = navigator.clone(); + let on_page = is_on_profiling_page; + move |_| { + *PROFILING_VIEW.write() = v.clone(); + if !on_page { + nav.push(Route::ProfilingPage {}); + } + } + }, + Icon { icon, class: "w-4 h-4" } + span { "{label}" } + if is_selected { + span { class: "{check_class}", "✓" } + } + } + } +} + +#[component] +pub fn ProfilingControlsPanel() -> Element { + let current_view = PROFILING_VIEW.read(); + let panel_border_class = format!("mt-4 pt-4 border-t border-{}", colors::SIDEBAR_BORDER); + let control_title_class = + format!("text-xs font-semibold text-{}", colors::SIDEBAR_TEXT_SECONDARY); + let control_value_class = format!("text-xs text-{}", colors::SIDEBAR_TEXT_MUTED); + let toggle_enabled_class = format!( + "relative inline-flex h-6 w-11 items-center rounded-full transition-colors w-full bg-{}", + colors::PRIMARY + ); + let toggle_disabled_class = format!( + "relative inline-flex h-6 w-11 items-center rounded-full transition-colors w-full bg-{}", + colors::SIDEBAR_ACTIVE_BG + ); + let toggle_label_class = + format!("ml-2 text-xs text-{}", colors::SIDEBAR_TEXT_SECONDARY); + let input_class = format!( + "w-full px-2 py-1 border border-{} bg-{} text-{} rounded text-xs focus:border-{} focus:outline-none", + colors::SIDEBAR_INPUT_BORDER, + colors::SIDEBAR_INPUT_BG, + colors::SIDEBAR_TEXT_SECONDARY, + colors::PRIMARY_BORDER + ); + + rsx! { + div { + class: "{panel_border_class}", + div { + class: "px-3 space-y-4", + { + let view = (*current_view).clone(); + let content: Element = match view.as_str() { + "pprof" => rsx! { + PprofControls { + control_title_class: control_title_class.clone(), + control_value_class: control_value_class.clone(), + } + }, + "torch" => rsx! { + TorchControls { + control_title_class: control_title_class.clone(), + toggle_enabled_class: toggle_enabled_class.clone(), + toggle_disabled_class: toggle_disabled_class.clone(), + toggle_label_class: toggle_label_class.clone(), + } + }, + "trace-timeline" => rsx! { + TraceTimelineControls { + control_title_class: control_title_class.clone(), + control_value_class: control_value_class.clone(), + input_class: input_class.clone(), + } + }, + "pytorch-timeline" => rsx! { + PyTorchTimelineControls { + control_title_class: control_title_class.clone(), + input_class: input_class.clone(), + } + }, + "ray-timeline" => rsx! { + RayTimelineControls { + control_title_class: control_title_class.clone(), + input_class: input_class.clone(), + } + }, + _ => rsx! { div {} }, + }; + content + } + } + } + } +} diff --git a/web/src/components/sidebar/resize.rs b/web/src/components/sidebar/resize.rs new file mode 100644 index 00000000..35c5ecd7 --- /dev/null +++ b/web/src/components/sidebar/resize.rs @@ -0,0 +1,57 @@ +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 hover_class = format!("hover:bg-{}/50", colors::PRIMARY); + let active_class = if *is_resizing.read() { + format!("bg-{}", colors::PRIMARY) + } else { + "bg-transparent".to_string() + }; + let drag_handle_class = format!( + "absolute top-0 right-0 w-1 h-full cursor-col-resize {} transition-colors group z-20 {}", + hover_class, active_class + ); + + rsx! { + div { + class: "{drag_handle_class}", + onmousedown: move |ev| { + *is_resizing.write() = true; + *drag_start_x.write() = ev.element_coordinates().x as f64; + *drag_start_width.write() = *SIDEBAR_WIDTH.read(); + ev.prevent_default(); + }, + onmousemove: move |ev| { + if *is_resizing.read() { + let current_x = ev.element_coordinates().x as f64; + let delta_x = current_x - *drag_start_x.read(); + let new_width = + (*drag_start_width.read() + delta_x).max(200.0).min(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/table_view.rs b/web/src/components/table_view.rs index 33bdcee8..2e99f8c0 100644 --- a/web/src/components/table_view.rs +++ b/web/src/components/table_view.rs @@ -15,9 +15,12 @@ pub fn TableView( class: "w-full border-collapse table-auto", thead { - tr { class: "bg-gray-50 border-b border-gray-200", - for header in headers { - th { class: "px-4 py-2 text-left font-semibold text-gray-700 border-r border-gray-200", {header} } + tr { class: "bg-gray-50 border-b border-gray-200 sticky top-0 z-10", + for (col_idx, header) in headers.iter().enumerate() { + th { + class: format!("px-4 py-2 text-left font-semibold text-gray-700 border-r border-gray-200 bg-gray-50 {} {}", if col_idx == 0 { "sticky left-0 z-10" } else { "" }, ""), + {header.clone()} + } } } } @@ -25,14 +28,17 @@ pub fn TableView( tbody { for (row_idx, row) in data.iter().enumerate() { tr { - class: if row_idx % 2 == 0 { "bg-white" } else { "bg-gray-50" }, + class: if row_idx % 2 == 0 { "bg-white hover:bg-gray-50" } else { "bg-gray-50 hover:bg-gray-100" }, onclick: move |_| { if let Some(cb) = on_row_click { cb.call(row_idx); } }, - for cell in row { - td { class: "px-4 py-2 text-gray-700 border-r border-gray-200", {cell.clone()} } + for (cell_idx, cell) in row.iter().enumerate() { + td { + class: format!("px-4 py-2 text-gray-700 border-r border-gray-200 {} {}", if cell_idx == 0 { "sticky left-0 z-[1]" } else { "" }, if cell_idx == 0 && row_idx % 2 == 0 { "bg-white" } else if cell_idx == 0 { "bg-gray-50" } else { "" }), + {cell.clone()} + } } } } diff --git a/web/src/hooks.rs b/web/src/hooks.rs index f9b524e7..0175abc0 100644 --- a/web/src/hooks.rs +++ b/web/src/hooks.rs @@ -35,8 +35,8 @@ pub fn use_api_simple() -> ApiState { /// Generic API call hook (auto-executes) /// -/// Automatically executes API call when component mounts, and re-executes when dependencies change. -/// Uses cached ApiClient instance for better performance. +/// Automatically executes API call when component mounts, and re-executes when any reactive +/// dependency (e.g. Signals read inside the closure) changes. pub fn use_api(mut fetch_fn: F) -> ApiState where T: Clone + 'static, diff --git a/web/src/main.rs b/web/src/main.rs index 265dcbe4..7ad29f33 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -5,7 +5,7 @@ mod app; mod components; mod hooks; mod pages; -mod styles; +mod state; mod utils; use app::App; diff --git a/web/src/pages/analytics.rs b/web/src/pages/analytics.rs index 4921d7a8..9b1d6451 100644 --- a/web/src/pages/analytics.rs +++ b/web/src/pages/analytics.rs @@ -1,4 +1,6 @@ use dioxus::prelude::*; + +use crate::components::colors::colors; use crate::components::card::Card; use crate::components::dataframe_view::DataFrameView; use crate::components::page::{PageContainer, PageTitle}; @@ -21,12 +23,12 @@ pub fn Analytics() -> Element { PageContainer { PageTitle { title: "Analytics".to_string(), - subtitle: Some("Query and analyze performance data with SQL".to_string()), + subtitle: Some("SQL and tables".to_string()), icon: Some(&icondata::AiAreaChartOutlined), } Card { title: "Tables", - content_class: Some("") , + content_class: Some(""), // no extra padding for table card if tables_state.is_loading() { LoadingState { message: Some("Loading tables...".to_string()) } } else if let Some(Ok(df)) = tables_state.data.read().as_ref() { @@ -59,7 +61,7 @@ pub fn Analytics() -> Element { rsx!{ DataFrameView { df: df.clone(), on_row_click: Some(handler) } } } } else if let Some(Err(err)) = tables_state.data.read().as_ref() { - ErrorState { error: format!("{:?}", err), title: None } + ErrorState { error: err.display_message(), title: None } } } @@ -75,7 +77,7 @@ pub fn Analytics() -> Element { // Header div { class: "flex items-center justify-between mb-3", h3 { class: "text-lg font-semibold text-gray-900", "{preview_title}" } - button { class: "px-3 py-1 text-sm rounded bg-gray-100 hover:bg-gray-200", + button { class: format!("px-3 py-1 text-sm rounded bg-{} hover:bg-{}", colors::BTN_SECONDARY_BG, colors::BTN_SECONDARY_HOVER), onclick: move |_| { *preview_open.write() = false; }, @@ -88,7 +90,7 @@ pub fn Analytics() -> Element { } else if let Some(Ok(df)) = preview_state.data.read().as_ref() { DataFrameView { df: df.clone(), on_row_click: None } } else if let Some(Err(err)) = preview_state.data.read().as_ref() { - ErrorState { error: format!("{:?}", err), title: None } + ErrorState { error: err.display_message(), title: None } } else { span { class: "text-gray-500", "Preparing preview..." } } @@ -142,7 +144,7 @@ fn SqlQueryPanel() -> Element { } button { - class: format!("px-6 py-2 bg-indigo-600 text-white rounded-md font-medium hover:bg-indigo-700 transition-colors shadow-sm {}", if *is_executing.read() { "opacity-50 cursor-not-allowed" } else { "" }), + class: format!("px-6 py-2 bg-{} text-white rounded-md font-medium hover:bg-{} transition-colors shadow-sm {}", colors::PRIMARY, colors::PRIMARY_HOVER, if *is_executing.read() { "opacity-50 cursor-not-allowed" } else { "" }), disabled: *is_executing.read(), onclick: execute_query, if *is_executing.read() { "Running..." } else { "Run Query" } @@ -153,7 +155,7 @@ fn SqlQueryPanel() -> Element { } else if let Some(Ok(df)) = query_state.data.read().as_ref() { DataFrameView { df: df.clone(), on_row_click: None } } else if let Some(Err(err)) = query_state.data.read().as_ref() { - ErrorState { error: format!("{:?}", err), title: None } + ErrorState { error: err.display_message(), title: None } } } } diff --git a/web/src/pages/chrome_tracing.rs b/web/src/pages/chrome_tracing.rs index e446b765..a50e2fb0 100644 --- a/web/src/pages/chrome_tracing.rs +++ b/web/src/pages/chrome_tracing.rs @@ -1,8 +1,10 @@ use dioxus::prelude::*; + +use crate::api::{ApiClient, ProfileResponse}; +use crate::components::chrome_tracing_iframe::ChromeTracingIframe; +use crate::components::colors::colors; use crate::components::page::{PageContainer, PageTitle}; -use crate::components::common::{LoadingState, ErrorState}; use crate::hooks::use_api_simple; -use crate::api::{ApiClient, ProfileResponse}; #[component] pub fn ChromeTracing() -> Element { @@ -11,7 +13,7 @@ pub fn ChromeTracing() -> Element { let pytorch_steps = use_signal(|| 5i32); let state = use_api_simple::(); let profile_state = use_api_simple::(); - let mut iframe_key = use_signal(|| 0); + let iframe_key = use_signal(|| 0); // Create dependency, recalculate when limit changes let limit_value = use_memo({ @@ -53,7 +55,7 @@ pub fn ChromeTracing() -> Element { PageContainer { PageTitle { title: "Chrome Tracing".to_string(), - subtitle: Some("View timeline in Chrome DevTools tracing format".to_string()), + subtitle: Some("Chrome DevTools timeline".to_string()), icon: Some(&icondata::AiThunderboltOutlined), } @@ -68,18 +70,18 @@ pub fn ChromeTracing() -> Element { } button { class: if *data_source.read() == "trace" { - "px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white" + format!("px-4 py-2 text-sm font-medium rounded-md bg-{} text-white", colors::PRIMARY) } else { - "px-4 py-2 text-sm font-medium rounded-md bg-gray-200 text-gray-700 hover:bg-gray-300" + format!("px-4 py-2 text-sm font-medium rounded-md bg-{} text-gray-700 hover:bg-{}", colors::BTN_SECONDARY_BG, colors::BTN_SECONDARY_HOVER) }, onclick: move |_| *data_source.write() = "trace".to_string(), "Trace Events" } button { class: if *data_source.read() == "pytorch" { - "px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white" + format!("px-4 py-2 text-sm font-medium rounded-md bg-{} text-white", colors::PRIMARY) } else { - "px-4 py-2 text-sm font-medium rounded-md bg-gray-200 text-gray-700 hover:bg-gray-300" + format!("px-4 py-2 text-sm font-medium rounded-md bg-{} text-gray-700 hover:bg-{}", colors::BTN_SECONDARY_BG, colors::BTN_SECONDARY_HOVER) }, onclick: move |_| *data_source.write() = "pytorch".to_string(), "PyTorch Profiler" @@ -157,7 +159,7 @@ pub fn ChromeTracing() -> Element { div { class: "flex items-center space-x-4", button { - class: "px-4 py-2 text-sm font-medium rounded-md bg-green-600 text-white hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed", + class: format!("px-4 py-2 text-sm font-medium rounded-md bg-{} text-white hover:bg-{} disabled:bg-gray-400 disabled:cursor-not-allowed", colors::SUCCESS, colors::SUCCESS_HOVER), disabled: profile_state.is_loading(), onclick: { let mut profile_state = profile_state.clone(); @@ -179,7 +181,7 @@ pub fn ChromeTracing() -> Element { } } button { - class: "px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed", + class: format!("px-4 py-2 text-sm font-medium rounded-md bg-{} text-white hover:bg-{} disabled:bg-gray-400 disabled:cursor-not-allowed", colors::PRIMARY, colors::PRIMARY_HOVER), disabled: state.is_loading(), onclick: { let mut state = state.clone(); @@ -223,7 +225,7 @@ pub fn ChromeTracing() -> Element { } } else { div { - class: "mt-2 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-800", + class: format!("mt-2 p-2 bg-{} border border-{} rounded text-sm text-{}", colors::ERROR_LIGHT, colors::ERROR_BORDER, colors::ERROR_TEXT), if let Some(ref err) = profile_result.error { "{err}" } else { @@ -236,70 +238,36 @@ pub fn ChromeTracing() -> Element { } } - // Chrome Tracing Viewer - if state.is_loading() { - LoadingState { - message: Some(if *data_source.read() == "pytorch" { - "Loading PyTorch timeline data...".to_string() - } else { - "Loading trace data...".to_string() - }) - } - } else if let Some(Ok(ref trace_json)) = state.data.read().as_ref() { - // Use loaded data directly for display - // Validate that data is valid JSON - if trace_json.trim().is_empty() { - ErrorState { - error: "Timeline data is empty. Make sure the profiler has been executed.".to_string(), - title: Some("Empty Timeline Data".to_string()) - } - } else if let Err(e) = serde_json::from_str::(trace_json) { - ErrorState { - error: format!("Invalid JSON data: {:?}", e), - title: Some("Invalid Timeline Data".to_string()) - } - } else { - div { - class: "bg-white rounded-lg shadow overflow-hidden", - style: "height: calc(100vh - 300px); min-height: 600px;", - iframe { - key: "{*iframe_key.read()}", - srcdoc: get_tracing_viewer_html(trace_json), - style: "width: 100%; height: 100%; border: none;", - title: "Chrome Tracing Viewer" + // Chrome Tracing Viewer (shared component + custom no-data placeholder) + { + let show_placeholder = !state.is_loading() && state.data.read().as_ref().is_none(); + if show_placeholder { + rsx! { + div { + class: "bg-white rounded-lg shadow p-8 text-center", + div { + class: "text-gray-500", + if *data_source.read() == "pytorch" { + p { class: "mb-4 text-lg", "PyTorch Profiler Timeline" } + p { class: "text-sm", "Click 'Start Profile' to begin profiling, then click 'Load Timeline' to view the results." } + } else { + p { class: "mb-4 text-lg", "Trace Events Timeline" } + p { class: "text-sm", "Select the number of events and the timeline will load automatically." } + } + } } } - } - } else if let Some(Err(ref err)) = state.data.read().as_ref() { - // Display error message - ErrorState { - error: format!("Failed to load timeline: {:?}", err), - title: Some("Load Timeline Error".to_string()) - } - } else { - // No data, display hint message - div { - class: "bg-white rounded-lg shadow p-8 text-center", - div { - class: "text-gray-500", - if *data_source.read() == "pytorch" { - p { - class: "mb-4 text-lg", - "PyTorch Profiler Timeline" - } - p { - class: "text-sm", - "Click 'Start Profile' to begin profiling, then click 'Load Timeline' to view the results." - } - } else { - p { - class: "mb-4 text-lg", - "Trace Events Timeline" - } - p { - class: "text-sm", - "Select the number of events and the timeline will load automatically." - } + } else { + rsx! { + ChromeTracingIframe { + state: state.clone(), + iframe_key: iframe_key, + loading_message: Some(if *data_source.read() == "pytorch" { + "Loading PyTorch timeline data...".to_string() + } else { + "Loading trace data...".to_string() + }), + empty_message: Some("Timeline data is empty. Make sure the profiler has been executed.".to_string()), } } } @@ -307,543 +275,3 @@ pub fn ChromeTracing() -> Element { } } } - -/// Generate tracing viewer URL, specify JSON data to load via URL parameters -/// Create an HTML page that gets JSON URL from URL parameters, then loads it into Perfetto UI -fn get_tracing_viewer_url(data_source: String, limit: usize) -> String { - // Build API URL to fetch JSON data - let api_path = if data_source == "pytorch" { - "/apis/pythonext/pytorch/timeline".to_string() - } else { - format!("/apis/pythonext/trace/chrome-tracing?limit={}", limit) - }; - - // Get current page origin - let origin = web_sys::window() - .and_then(|w| w.location().origin().ok()) - .unwrap_or_else(|| "http://localhost:8080".to_string()); - - let json_url = format!("{}{}", origin, api_path); - - // Create an HTML page that passes JSON URL via URL parameters - // This allows iframe to automatically load remote JSON data - get_tracing_viewer_html_with_url(&json_url) -} - -/// Generate HTML page containing Chrome tracing viewer -/// Get JSON data URL from URL parameters, then automatically load into Perfetto UI -/// First fetch JSON data, then pass to Perfetto UI via blob URL to avoid CORS issues -fn get_tracing_viewer_html_with_url(json_url: &str) -> String { - // Escape URL for embedding in JavaScript - let escaped_url = json_url - .replace('\\', "\\\\") - .replace('`', "\\`") - .replace('$', "\\$"); - - format!(r#" - - - - - Chrome Tracing Viewer - - - -
Loading Chrome Tracing Viewer...
- - - - - "#) -} - -/// Generate HTML page containing Chrome tracing viewer -/// Directly use loaded trace JSON data, pass to Perfetto UI via postMessage API -pub fn get_tracing_viewer_html(trace_json: &str) -> String { - // Escape JSON data for embedding in JavaScript - let escaped_json = trace_json - .replace('\\', "\\\\") - .replace('`', "\\`") - .replace('$', "\\$"); - - format!(r#" - - - - - Chrome Tracing Viewer - - - -
Loading Chrome Tracing Viewer...
- - - - - "#) -} diff --git a/web/src/pages/cluster.rs b/web/src/pages/cluster.rs index 48a0d294..dcd92f4c 100644 --- a/web/src/pages/cluster.rs +++ b/web/src/pages/cluster.rs @@ -5,8 +5,9 @@ use dioxus::prelude::*; use probing_proto::prelude::*; use crate::components::card::Card; +use crate::components::colors::colors; +use crate::components::common::{EmptyState, ErrorState, LoadingState}; use crate::components::page::{PageContainer, PageTitle}; -use crate::components::common::{LoadingState, ErrorState, EmptyState}; use crate::hooks::use_api; use crate::api::ApiClient; @@ -21,7 +22,7 @@ pub fn Cluster() -> Element { PageContainer { PageTitle { title: "Cluster Management".to_string(), - subtitle: Some("Monitor and manage your cluster nodes".to_string()), + subtitle: Some("Cluster nodes".to_string()), icon: Some(&icondata::AiClusterOutlined), } if state.is_loading() { @@ -106,7 +107,7 @@ fn ClusterTable(nodes: Vec) -> Element { td { class: "px-4 py-2 text-gray-700 border-r border-gray-200", a { href: "{url}", - class: "text-indigo-600 hover:text-indigo-800 hover:underline transition-colors", + class: format!("text-{} hover:text-{} hover:underline transition-colors", colors::PRIMARY, colors::PRIMARY_HOVER), {node.addr.clone()} } } diff --git a/web/src/pages/dashboard.rs b/web/src/pages/dashboard.rs index c54286e8..1f412c97 100644 --- a/web/src/pages/dashboard.rs +++ b/web/src/pages/dashboard.rs @@ -1,12 +1,15 @@ +//! Dashboard: process info, threads, env vars. Single use_api, content by state. + use dioxus::prelude::*; +use crate::api::ApiClient; use crate::components::card::Card; use crate::components::card_view::ThreadsCard; +use crate::components::common::{ErrorState, LoadingState}; use crate::components::data::KeyValueList; use crate::components::page::{PageContainer, PageTitle}; -use crate::components::common::{LoadingState, ErrorState}; use crate::hooks::use_api; -use crate::api::ApiClient; +use probing_proto::prelude::Process; #[component] pub fn Dashboard() -> Element { @@ -19,45 +22,59 @@ pub fn Dashboard() -> Element { PageContainer { PageTitle { title: "Dashboard".to_string(), - subtitle: Some("System overview and process information".to_string()), + subtitle: Some("Process and threads".to_string()), icon: Some(&icondata::AiLineChartOutlined), } - if state.is_loading() { - Card { - title: "Loading", - LoadingState { message: Some("Loading process information...".to_string()) } - } - } else if let Some(Ok(process)) = state.data.read().as_ref() { - 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()}" } - ThreadsCard { threads: process.threads.clone() } - } - } - Card { - title: "Environment Variables", - EnvVars { env: process.env.clone() } - } - } else if let Some(Err(err)) = state.data.read().as_ref() { - Card { - title: "Error", - ErrorState { error: format!("{:?}", err), title: None } - } + {dashboard_content(&state)} + } + } +} + +fn dashboard_content(state: &crate::hooks::ApiState) -> Element { + if state.is_loading() { + return rsx! { + Card { + title: "Loading", + 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: "Error", + ErrorState { error: err.display_message(), title: None } + } + }; + } + let Some(Ok(process)) = data.as_ref() else { + return rsx! { div {} }; + }; + rsx! { + 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()}" } + ThreadsCard { threads: process.threads.clone() } } } + Card { + title: "Environment Variables", + EnvVars { env: process.env.clone() } + } } } @@ -73,7 +90,7 @@ fn EnvVars(env: std::collections::HashMap) -> Element { 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", "{name}" } - span { class: "font-mono text-sm bg-gray-100 px-6 py-2 rounded break-all", "{value}" } + span { class: "font-mono text-sm bg-gray-100 text-gray-900 px-6 py-2 rounded break-all", "{value}" } } } } diff --git a/web/src/pages/profiling.rs b/web/src/pages/profiling.rs index 68eed78c..07fa4f0d 100644 --- a/web/src/pages/profiling.rs +++ b/web/src/pages/profiling.rs @@ -3,9 +3,11 @@ use crate::components::common::{LoadingState, ErrorState, EmptyState}; use crate::components::page::{PageContainer, PageTitle}; use crate::hooks::use_api_simple; use crate::api::{ApiClient, ProfileResponse}; -use crate::app::{PROFILING_VIEW, PROFILING_PPROF_FREQ, PROFILING_TORCH_ENABLED, - PROFILING_CHROME_LIMIT, PROFILING_PYTORCH_TIMELINE_RELOAD, PROFILING_RAY_TIMELINE_RELOAD}; -use crate::pages::chrome_tracing::get_tracing_viewer_html; +use crate::state::profiling::{ + PROFILING_CHROME_LIMIT, PROFILING_PPROF_FREQ, PROFILING_PYTORCH_TIMELINE_RELOAD, + PROFILING_RAY_TIMELINE_RELOAD, PROFILING_TORCH_ENABLED, PROFILING_VIEW, +}; +use crate::components::chrome_tracing_iframe::ChromeTracingIframe; fn apply_config(config: &[(String, String)]) { *PROFILING_PPROF_FREQ.write() = 0; @@ -36,7 +38,7 @@ pub fn Profiling() -> Element { let config_state = use_api_simple::>(); let flamegraph_state = use_api_simple::(); let chrome_tracing_state = use_api_simple::(); - let pytorch_profile_state = use_api_simple::(); + let _pytorch_profile_state = use_api_simple::(); let ray_timeline_state = use_api_simple::(); // Changed to String for Chrome format JSON use_effect(move || { @@ -162,33 +164,33 @@ pub fn Profiling() -> Element { let current_view = PROFILING_VIEW.read(); let (title, subtitle, icon) = match current_view.as_str() { "pprof" => ( - "pprof Flamegraph".to_string(), - Some("CPU profiling with pprof".to_string()), + "pprof".to_string(), + Some("CPU flamegraph".to_string()), Some(&icondata::CgPerformance), ), "torch" => ( - "torch Flamegraph".to_string(), - Some("PyTorch profiling visualization".to_string()), + "torch".to_string(), + Some("PyTorch flamegraph".to_string()), Some(&icondata::SiPytorch), ), "trace-timeline" => ( - "Trace Timeline".to_string(), - Some("Chrome Tracing timeline view".to_string()), + "Trace".to_string(), + Some("Chrome timeline".to_string()), Some(&icondata::AiThunderboltOutlined), ), "pytorch-timeline" => ( - "PyTorch Timeline".to_string(), - Some("PyTorch profiler timeline view".to_string()), + "PyTorch".to_string(), + Some("Profiler timeline".to_string()), Some(&icondata::SiPytorch), ), "ray-timeline" => ( - "Ray Timeline".to_string(), - Some("Ray task and actor execution timeline".to_string()), + "Ray".to_string(), + Some("Ray timeline".to_string()), Some(&icondata::AiClockCircleOutlined), ), _ => ( "Profiling".to_string(), - Some("Performance profiling and analysis".to_string()), + Some("Profiling views".to_string()), Some(&icondata::AiSearchOutlined), ), }; @@ -202,7 +204,7 @@ pub fn Profiling() -> Element { } div { - class: "bg-white rounded-lg shadow-sm border border-gray-200 relative", + class: "bg-white rounded-lg border border-gray-200 relative", style: "min-height: calc(100vh - 12rem);", { let current_view = PROFILING_VIEW.read().clone(); @@ -283,7 +285,7 @@ fn FlamegraphView( if let Some(Err(err)) = flamegraph_state.data.read().as_ref() { return rsx! { ErrorState { - error: format!("Failed to load flamegraph: {:?}", err), + error: format!("Failed to load flamegraph: {}", err.display_message()), title: Some("Error Loading Flamegraph".to_string()) } }; @@ -300,81 +302,49 @@ fn ChromeTracingView( let current_view = PROFILING_VIEW.read().clone(); let is_pytorch = current_view == "pytorch-timeline"; - if chrome_tracing_state.is_loading() { - let message = if is_pytorch { - "Loading PyTorch timeline data..." + let loading_msg = if is_pytorch { + "Loading PyTorch timeline data..." + } else { + "Loading trace data..." + }; + let empty_msg = "Timeline data is empty. Make sure the profiler has been executed."; + + let no_data_placeholder = (!chrome_tracing_state.is_loading() + && chrome_tracing_state.data.read().as_ref().is_none()) + .then(|| { + let (title, description) = if is_pytorch { + ( + "PyTorch Profiler Timeline", + "Click 'Start Profile' to begin profiling, then click 'Load Timeline' to view the results.", + ) } else { - "Loading trace data..." + ( + "Trace Events Timeline", + "Select the number of events and the timeline will load automatically.", + ) }; - return rsx! { - LoadingState { message: Some(message.to_string()) } - }; - } - - if let Some(Ok(ref trace_json)) = chrome_tracing_state.data.read().as_ref() { - if trace_json.trim().is_empty() { - return rsx! { - ErrorState { - error: "Timeline data is empty. Make sure the profiler has been executed.".to_string(), - title: Some("Empty Timeline Data".to_string()) - } - }; - } - - if let Err(e) = serde_json::from_str::(trace_json) { - return rsx! { - ErrorState { - error: format!("Invalid JSON data: {:?}", e), - title: Some("Invalid Timeline Data".to_string()) - } - }; - } - - return rsx! { + rsx! { div { - class: "absolute inset-0 overflow-hidden", - style: "min-height: 600px;", - iframe { - key: "{*chrome_iframe_key.read()}", - srcdoc: get_tracing_viewer_html(trace_json), - style: "width: 100%; height: 100%; border: none;", - title: "Chrome Tracing Viewer" + class: "absolute inset-0 flex items-center justify-center p-8", + div { + class: "text-center text-gray-500", + p { class: "mb-4 text-lg", "{title}" } + p { class: "text-sm", "{description}" } } } - }; - } + } + }); - if let Some(Err(ref err)) = chrome_tracing_state.data.read().as_ref() { - return rsx! { - ErrorState { - error: format!("Failed to load timeline: {:?}", err), - title: Some("Load Timeline Error".to_string()) - } - }; + if let Some(placeholder) = no_data_placeholder { + return placeholder; } - let (title, description) = if is_pytorch { - ("PyTorch Profiler Timeline", - "Click 'Start Profile' to begin profiling, then click 'Load Timeline' to view the results.") - } else { - ("Trace Events Timeline", - "Select the number of events and the timeline will load automatically.") - }; - rsx! { - div { - class: "absolute inset-0 flex items-center justify-center p-8", - div { - class: "text-center text-gray-500", - p { - class: "mb-4 text-lg", - "{title}" - } - p { - class: "text-sm", - "{description}" - } - } + ChromeTracingIframe { + state: chrome_tracing_state.clone(), + iframe_key: chrome_iframe_key, + loading_message: Some(loading_msg.to_string()), + empty_message: Some(empty_msg.to_string()), } } } @@ -384,92 +354,31 @@ fn RayTimelineView( #[props] ray_timeline_state: crate::hooks::ApiState, #[props] chrome_iframe_key: Signal, ) -> Element { - if ray_timeline_state.is_loading() { - return rsx! { - LoadingState { message: Some("Loading Ray timeline data...".to_string()) } - }; - } - - if let Some(Ok(ref trace_json)) = ray_timeline_state.data.read().as_ref() { - if trace_json.trim().is_empty() { - return rsx! { - ErrorState { - error: "No Ray timeline data available. Start Ray tasks with probing tracing enabled.".to_string(), - title: Some("Empty Timeline Data".to_string()) - } - }; - } - - // Validate JSON - if let Err(e) = serde_json::from_str::(trace_json) { - return rsx! { - ErrorState { - error: format!("Invalid JSON data: {:?}", e), - title: Some("Invalid Timeline Data".to_string()) - } - }; - } - - // Use Perfetto UI to display (same as Chrome tracing) - return rsx! { - div { - class: "bg-white rounded-lg shadow overflow-hidden", - style: "height: calc(100vh - 300px); min-height: 600px;", - iframe { - key: "{*chrome_iframe_key.read()}", - srcdoc: get_tracing_viewer_html(trace_json), - style: "width: 100%; height: 100%; border: none;", - title: "Ray Timeline Viewer (Perfetto)" + let no_data_placeholder = + (!ray_timeline_state.is_loading() && ray_timeline_state.data.read().as_ref().is_none()) + .then(|| { + rsx! { + div { + class: "bg-white rounded-lg shadow p-8 text-center", + div { + class: "text-gray-500", + p { class: "mb-4 text-lg", "Ray Timeline" } + p { class: "text-sm", "Click 'Reload Ray Timeline' to load the timeline data." } + } + } } - } - }; - } + }); - if let Some(Err(err)) = ray_timeline_state.data.read().as_ref() { - return rsx! { - ErrorState { - error: format!("Failed to load Ray timeline: {:?}", err), - title: Some("Load Timeline Error".to_string()) - } - }; + if let Some(placeholder) = no_data_placeholder { + return placeholder; } rsx! { - div { - class: "bg-white rounded-lg shadow p-8 text-center", - div { - class: "text-gray-500", - p { - class: "mb-4 text-lg", - "Ray Timeline" - } - p { - class: "text-sm", - "Click 'Reload Ray Timeline' to load the timeline data." - } - } - } - } -} - - -#[component] -fn PyTorchProfileStatus(profile_result: ProfileResponse) -> Element { - if profile_result.success { - let message = profile_result.message.as_deref().unwrap_or("Profile started successfully"); - rsx! { - div { - class: "p-3 bg-green-50 border border-green-200 rounded text-sm text-green-800", - "{message}" - } - } - } else { - let error = profile_result.error.as_deref().unwrap_or("Failed to start profile"); - rsx! { - div { - class: "p-3 bg-red-50 border border-red-200 rounded text-sm text-red-800", - "{error}" - } + ChromeTracingIframe { + state: ray_timeline_state.clone(), + iframe_key: chrome_iframe_key, + loading_message: Some("Loading Ray timeline data...".to_string()), + empty_message: Some("No Ray timeline data available. Start Ray tasks with probing tracing enabled.".to_string()), } } } diff --git a/web/src/pages/python.rs b/web/src/pages/python.rs index 244ebd21..da56dce4 100644 --- a/web/src/pages/python.rs +++ b/web/src/pages/python.rs @@ -1,10 +1,12 @@ use dioxus::prelude::*; -use crate::components::page::{PageContainer, PageTitle}; -use crate::components::common::{LoadingState, ErrorState, EmptyState}; + +use crate::api::{ApiClient, TraceableItem}; +use crate::components::colors::colors; +use crate::components::common::{EmptyState, ErrorState, LoadingState}; use crate::components::dataframe_view::DataFrameView; +use crate::components::page::{PageContainer, PageTitle}; use crate::hooks::{use_api, use_api_simple}; -use crate::api::{ApiClient, TraceableItem}; #[component] @@ -15,7 +17,7 @@ pub fn Python() -> Element { PageContainer { PageTitle { title: "Python".to_string(), - subtitle: Some("Inspect and debug Python processes".to_string()), + subtitle: Some("Python trace and debug".to_string()), icon: Some(&icondata::SiPython), } div { @@ -24,7 +26,7 @@ pub fn Python() -> Element { class: "flex space-x-8", button { class: if *selected_tab.read() == "trace" { - "py-4 px-1 border-b-2 border-indigo-500 font-medium text-sm text-indigo-600" + format!("py-4 px-1 border-b-2 border-{} font-medium text-sm text-{}", colors::PRIMARY_BORDER, colors::PRIMARY) } else { "py-4 px-1 border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300" }, @@ -44,7 +46,7 @@ pub fn Python() -> Element { #[component] fn TraceView() -> Element { let _selected_function_filter = use_signal(|| Option::::None); - let mut refresh_key = use_signal(|| 0); + let refresh_key = use_signal(|| 0); let functions_state = use_api(move || { let client = ApiClient::new(); @@ -61,8 +63,8 @@ fn TraceView() -> Element { }); let records_state = use_api_simple::(); - let mut preview_function_name = use_signal(|| String::new()); - let mut preview_open = use_signal(|| false); + let preview_function_name = use_signal(|| String::new()); + let preview_open = use_signal(|| false); let mut dialog_open = use_signal(|| false); let mut dialog_function_name = use_signal(|| String::new()); @@ -115,7 +117,7 @@ fn TraceView() -> Element { } } } else if let Some(Err(err)) = functions_state.data.read().as_ref() { - ErrorState { error: format!("{:?}", err), title: None } + ErrorState { error: err.display_message(), title: None } } else { EmptyState { message: "No data available".to_string() } } @@ -183,7 +185,7 @@ fn TraceableFunctionItem( let mut variables_expanded = use_signal(|| !variables.is_empty()); let variables_list = use_signal(|| variables.clone()); let children_state = use_signal(|| Option::>::None); - let mut loading = use_signal(|| false); + let loading = use_signal(|| false); let name_for_display = name.clone(); let _name_for_click = name.clone(); @@ -241,9 +243,9 @@ fn TraceableFunctionItem( { let item_type_clone = item_type_clone.clone(); let (badge_class, badge_text) = match item_type_clone.as_str() { - "F" => ("bg-indigo-100 text-indigo-700", "[F]".to_string()), - "M" => ("bg-green-100 text-green-700", "[M]".to_string()), - _ => ("bg-gray-100 text-gray-700", format!("[{}]", item_type_clone)), + "F" => (format!("bg-{} text-{}", colors::CONTENT_ACCENT_BG, colors::CONTENT_ACCENT_TEXT), "[F]".to_string()), + "M" => (format!("bg-{} text-{}", colors::SUCCESS_LIGHT, colors::SUCCESS_TEXT), "[M]".to_string()), + _ => ("bg-gray-100 text-gray-700".to_string(), format!("[{}]", item_type_clone)), }; rsx! { span { @@ -297,7 +299,7 @@ fn TraceableFunctionItem( rsx! { span { - class: "text-xs px-2 py-1 bg-indigo-50 text-indigo-700 rounded border border-indigo-200 cursor-pointer hover:bg-indigo-100 transition-colors", + class: format!("text-xs px-2 py-1 bg-{} text-{} rounded border border-{} cursor-pointer hover:bg-blue-100 transition-colors", colors::CONTENT_ACCENT_BG, colors::CONTENT_ACCENT_TEXT, colors::CONTENT_ACCENT_BORDER), onclick: move |_| { *click_signal.write() = (func_name.clone(), vec![var_clone.clone()]); }, @@ -408,7 +410,7 @@ fn ActiveTracesCard( } } } else if let Some(Err(err)) = trace_info_state.data.read().as_ref() { - ErrorState { error: format!("{:?}", err), title: None } + ErrorState { error: err.display_message(), title: None } } else { EmptyState { message: "No data available".to_string() } } @@ -451,7 +453,7 @@ fn ActiveTraceItem( "{func_name}" } button { - class: "px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 shadow-sm", + class: format!("px-3 py-1 bg-{} text-white text-sm rounded hover:bg-{} shadow-sm", colors::ERROR, colors::ERROR_HOVER), onclick: move |e| { e.stop_propagation(); let func = func_name_clone.clone(); @@ -497,7 +499,7 @@ fn VariableRecordsModal( "Variable Records: {preview_function_name.read()}" } button { - class: "px-3 py-1 text-sm rounded bg-gray-100 hover:bg-gray-200", + class: format!("px-3 py-1 text-sm rounded bg-{} hover:bg-{}", colors::BTN_SECONDARY_BG, colors::BTN_SECONDARY_HOVER), onclick: move |_| { *preview_open.write() = false; }, @@ -512,7 +514,7 @@ fn VariableRecordsModal( on_row_click: None } } else if let Some(Err(err)) = records_state.data.read().as_ref() { - ErrorState { error: format!("{:?}", err), title: None } + ErrorState { error: err.display_message(), title: None } } else { span { class: "text-gray-500", @@ -591,7 +593,7 @@ fn StartTraceDialog( div { class: "flex items-center gap-2", input { - class: "w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500", + class: format!("w-4 h-4 text-{} border-gray-300 rounded focus:ring-{}", colors::PRIMARY, colors::PRIMARY), r#type: "checkbox", checked: *dialog_print_to_terminal.read(), onchange: move |e| { @@ -623,7 +625,7 @@ fn StartTraceDialog( "Cancel" } button { - class: "px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 shadow-sm transition-colors", + class: format!("px-4 py-2 bg-{} text-white rounded-md hover:bg-{} focus:outline-none focus:ring-2 focus:ring-{} shadow-sm transition-colors", colors::PRIMARY, colors::PRIMARY_HOVER, colors::PRIMARY), onclick: move |_| { let func = dialog_function_name.read().clone(); let watch = dialog_watch_vars.read().clone(); diff --git a/web/src/pages/stack.rs b/web/src/pages/stack.rs index aa4327a2..bf2addf5 100644 --- a/web/src/pages/stack.rs +++ b/web/src/pages/stack.rs @@ -1,6 +1,8 @@ use dioxus::prelude::*; use probing_proto::prelude::CallFrame; +use crate::components::colors::colors; + use crate::components::card::Card; use crate::components::callstack_view::CallStackView; use crate::components::page::{PageContainer, PageTitle}; @@ -34,15 +36,15 @@ pub fn Stack(tid: Option) -> Element { header_right: Some(rsx! { div { class: "flex gap-2 items-center", span { class: "text-sm text-gray-600", "Mode:" } - button { class: format!("px-3 py-1 rounded transition-colors {}", if mode.read().as_str()=="py" { "bg-indigo-600 text-white shadow-sm" } else { "bg-gray-100 hover:bg-gray-200" }), + button { class: format!("px-3 py-1 rounded transition-colors {}", if mode.read().as_str()=="py" { format!("bg-{} text-white shadow-sm", colors::PRIMARY) } else { format!("bg-{} hover:bg-{}", colors::BTN_SECONDARY_BG, colors::BTN_SECONDARY_HOVER) }), onclick: move |_| { *mode.write() = String::from("py"); }, "Py" } - button { class: format!("px-3 py-1 rounded transition-colors {}", if mode.read().as_str()=="cpp" { "bg-indigo-600 text-white shadow-sm" } else { "bg-gray-100 hover:bg-gray-200" }), + button { class: format!("px-3 py-1 rounded transition-colors {}", if mode.read().as_str()=="cpp" { format!("bg-{} text-white shadow-sm", colors::PRIMARY) } else { format!("bg-{} hover:bg-{}", colors::BTN_SECONDARY_BG, colors::BTN_SECONDARY_HOVER) }), onclick: move |_| { *mode.write() = String::from("cpp"); }, "C++" } - button { class: format!("px-3 py-1 rounded transition-colors {}", if mode.read().as_str()=="mixed" { "bg-indigo-600 text-white shadow-sm" } else { "bg-gray-100 hover:bg-gray-200" }), + button { class: format!("px-3 py-1 rounded transition-colors {}", if mode.read().as_str()=="mixed" { format!("bg-{} text-white shadow-sm", colors::PRIMARY) } else { format!("bg-{} hover:bg-{}", colors::BTN_SECONDARY_BG, colors::BTN_SECONDARY_HOVER) }), onclick: move |_| { *mode.write() = String::from("mixed"); }, "Mixed" } @@ -74,7 +76,7 @@ pub fn Stack(tid: Option) -> Element { } } } else if let Some(Err(err)) = state.data.read().as_ref() { - ErrorState { error: format!("{:?}", err), title: None } + ErrorState { error: err.display_message(), title: None } } } } diff --git a/web/src/pages/traces.rs b/web/src/pages/traces.rs index 2781e57f..e95aedb5 100644 --- a/web/src/pages/traces.rs +++ b/web/src/pages/traces.rs @@ -1,5 +1,7 @@ use dioxus::prelude::*; + use crate::components::card::Card; +use crate::components::colors::colors; use crate::components::page::{PageContainer, PageTitle}; use crate::components::common::{LoadingState, ErrorState}; use crate::hooks::use_api_simple; @@ -37,7 +39,7 @@ pub fn Traces() -> Element { PageContainer { PageTitle { title: "Traces".to_string(), - subtitle: Some("Analyze span timing and nested relationships".to_string()), + subtitle: Some("Span tree and timing".to_string()), icon: Some(&icondata::AiApiOutlined), } // Limit control slider @@ -99,7 +101,7 @@ pub fn Traces() -> Element { } } } else if let Some(Err(err)) = state.data.read().as_ref() { - ErrorState { error: format!("{:?}", err), title: None } + ErrorState { error: err.display_message(), title: None } } } } @@ -139,7 +141,7 @@ fn SpanView(span: SpanInfo, depth: usize) -> Element { } if let Some(ref kind) = span.kind { span { - class: "text-xs px-2 py-0.5 bg-indigo-100 text-indigo-800 rounded", + class: format!("text-xs px-2 py-0.5 bg-{} text-{} rounded", colors::CONTENT_ACCENT_BG, colors::CONTENT_ACCENT_TEXT), "{kind}" } } diff --git a/web/src/state/mod.rs b/web/src/state/mod.rs new file mode 100644 index 00000000..8d383136 --- /dev/null +++ b/web/src/state/mod.rs @@ -0,0 +1,2 @@ +pub mod profiling; +pub mod sidebar; diff --git a/web/src/state/profiling.rs b/web/src/state/profiling.rs new file mode 100644 index 00000000..b30df883 --- /dev/null +++ b/web/src/state/profiling.rs @@ -0,0 +1,12 @@ +use dioxus::prelude::*; + +pub static PROFILING_VIEW: GlobalSignal = Signal::global(|| "pprof".to_string()); +pub static PROFILING_PPROF_FREQ: GlobalSignal = Signal::global(|| 99); +pub static PROFILING_TORCH_ENABLED: GlobalSignal = Signal::global(|| false); +#[allow(dead_code)] +pub static PROFILING_CHROME_DATA_SOURCE: GlobalSignal = + Signal::global(|| "trace".to_string()); +pub static PROFILING_CHROME_LIMIT: GlobalSignal = Signal::global(|| 1000); +pub static PROFILING_PYTORCH_STEPS: GlobalSignal = Signal::global(|| 5); +pub static PROFILING_PYTORCH_TIMELINE_RELOAD: GlobalSignal = Signal::global(|| 0); +pub static PROFILING_RAY_TIMELINE_RELOAD: GlobalSignal = Signal::global(|| 0); diff --git a/web/src/state/sidebar.rs b/web/src/state/sidebar.rs new file mode 100644 index 00000000..6e20a3f0 --- /dev/null +++ b/web/src/state/sidebar.rs @@ -0,0 +1,34 @@ +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/styles.rs b/web/src/styles.rs deleted file mode 100644 index a254cba8..00000000 --- a/web/src/styles.rs +++ /dev/null @@ -1,3 +0,0 @@ -/// Styles module -pub mod styles {} -pub mod combinations {} diff --git a/web/src/utils/error.rs b/web/src/utils/error.rs index b4936687..98f7830d 100644 --- a/web/src/utils/error.rs +++ b/web/src/utils/error.rs @@ -12,6 +12,13 @@ pub enum AppError { Api(String), } +impl AppError { + /// User-facing message for display in the UI (enables future i18n). + pub fn display_message(&self) -> String { + self.to_string() + } +} + impl From for AppError { fn from(err: reqwest::Error) -> Self { AppError::Network(err.to_string()) diff --git a/web/src/utils/mod.rs b/web/src/utils/mod.rs index a91e7351..117ee7f8 100644 --- a/web/src/utils/mod.rs +++ b/web/src/utils/mod.rs @@ -1 +1,2 @@ pub mod error; +pub mod tracing_viewer; diff --git a/web/src/utils/tracing_viewer.rs b/web/src/utils/tracing_viewer.rs new file mode 100644 index 00000000..a8cf0c16 --- /dev/null +++ b/web/src/utils/tracing_viewer.rs @@ -0,0 +1,207 @@ +//! Shared Chrome/Perfetto tracing viewer HTML generator. + +/// Generate HTML page containing Chrome tracing viewer. +/// Embeds trace JSON and loads Perfetto UI via postMessage API. +pub fn get_tracing_viewer_html(trace_json: &str) -> String { + let escaped_json = trace_json + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace('$', "\\$"); + + format!(r#" + + + + + Chrome Tracing Viewer + + + +
Loading Chrome Tracing Viewer...
+ + + + + "#) +} From 775e350a2a29bcbfda19a702b12700f4162c37eb Mon Sep 17 00:00:00 2001 From: Reiase Date: Mon, 23 Feb 2026 11:39:01 +0800 Subject: [PATCH 2/7] Enhance probing functionality by refining depth handling in tracer - Updated the `probe` function to handle `None` values for depth, defaulting to 1. - Modified the `ProbingTracer` class to ensure it correctly identifies when no depth limit is set. - Improved code clarity with additional comments for better understanding of depth management. --- python/probing/inspect/trace.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/python/probing/inspect/trace.py b/python/probing/inspect/trace.py index beb9df85..c6fe61a3 100644 --- a/python/probing/inspect/trace.py +++ b/python/probing/inspect/trace.py @@ -6,11 +6,12 @@ import os import sys import threading +import time import types import warnings from dataclasses import dataclass from types import FrameType, FunctionType, ModuleType -from typing import Any, AnyStr, Callable, Dict, List, Optional, Set +from typing import Any, AnyStr, Callable, Dict, List, Set, Optional from probing.core.table import table @@ -609,8 +610,12 @@ def wrapper(*args, **kwargs): raise RuntimeError(f"Probe attributes not found for code id {code_id}") ProbingTracer = getattr(_trace_module, "ProbingTracer") + # Handle None depth value - use default of 1 if None + probe_depth = attrs.get("__probe_depth__", 1) + if probe_depth is None: + probe_depth = 1 tracer = ProbingTracer( - attrs.get("__probe_depth__", 1), + probe_depth, attrs.get("__probe_watch__", []), attrs.get("__probe_silent_watch__", []), ) @@ -677,6 +682,9 @@ def on_return(self): def _outof_depth(self): depth = self.count_calls - self.count_returns + # If self.depth is None, it means no depth limit, so we're never out of depth + if self.depth is None: + return False return depth > self.depth def _is_internal_frame(self, frame): From 4b83d969de063f7a65580614fe443ff6d0fb4690 Mon Sep 17 00:00:00 2001 From: Reiase Date: Mon, 23 Feb 2026 17:24:02 +0800 Subject: [PATCH 3/7] Enhance Python REPL and profiling features - Updated `handle_eval` method to catch panics during REPL execution, preventing server crashes from bad requests. - Improved error handling in `PprofHolder` for profiler initialization failures. - Refactored `query_profiling` to utilize a global engine for torch profiling data, with fallback mechanisms. - Added new API endpoints for retrieving magic commands and executing Python code in the REPL. - Introduced a global command panel for UI quick actions, enhancing user interaction with magic commands. - Implemented security measures in tracing functions to prevent potential DoS attacks and input validation issues. --- .../python/src/extensions/python.rs | 14 +- .../extensions/python/src/features/pprof.rs | 14 +- .../extensions/python/src/features/torch.rs | 76 +-- probing/extensions/python/src/repl/console.rs | 38 +- python/probing/ext/torch.py | 8 + python/probing/handlers/pythonext.py | 36 +- python/probing/inspect/torch.py | 42 +- python/probing/inspect/trace.py | 91 ++- .../probing/profiling/torch/module_utils.py | 3 + python/probing/profiling/torch_probe.py | 85 ++- python/probing/repl/help_magic.py | 605 +++++++++++------- web/src/api/mod.rs | 3 + web/src/api/repl.rs | 61 ++ web/src/api/trace.rs | 15 +- web/src/components/global_command_panel.rs | 373 +++++++++++ web/src/components/layout.rs | 29 +- web/src/components/mod.rs | 1 + web/src/pages/python.rs | 77 ++- web/src/state/commands.rs | 29 + web/src/state/mod.rs | 1 + 20 files changed, 1248 insertions(+), 353 deletions(-) create mode 100644 web/src/api/repl.rs create mode 100644 web/src/components/global_command_panel.rs create mode 100644 web/src/state/commands.rs diff --git a/probing/extensions/python/src/extensions/python.rs b/probing/extensions/python/src/extensions/python.rs index 2d66f079..88075c36 100644 --- a/probing/extensions/python/src/extensions/python.rs +++ b/probing/extensions/python/src/extensions/python.rs @@ -165,7 +165,7 @@ impl PythonExt { }) } - /// Handle eval request + /// Handle eval request. Catches panics from REPL so a single bad request cannot crash the server. fn handle_eval(&self, body: &[u8]) -> Result, EngineError> { let code = String::from_utf8(body.to_vec()).map_err(|e| { log::error!("Failed to convert body to UTF-8 string: {e}"); @@ -175,7 +175,17 @@ impl PythonExt { log::debug!("Python eval code: {code}"); let mut repl = PythonRepl::default(); - Ok(repl.process(code.as_str()).unwrap_or_default().into_bytes()) + let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + repl.process(code.as_str()) + })); + match out { + Ok(Some(s)) => Ok(s.into_bytes()), + Ok(None) => Ok(Vec::new()), + Err(_) => { + log::error!("Python REPL process panicked; returning error response"); + Ok(serde_json::json!({"error": "REPL execution panicked"}).to_string().into_bytes()) + } + } } /// Set up a Python crash handler diff --git a/probing/extensions/python/src/features/pprof.rs b/probing/extensions/python/src/features/pprof.rs index 6bf7c66f..90668bc9 100644 --- a/probing/extensions/python/src/features/pprof.rs +++ b/probing/extensions/python/src/features/pprof.rs @@ -19,7 +19,10 @@ impl PprofHolder { let _ = self.0.lock().map(|mut holder| { match ProfilerGuardBuilder::default().frequency(freq).build() { Ok(ph) => holder.replace(ph), - Err(_) => todo!(), + Err(e) => { + log::error!("pprof ProfilerGuard build failed (freq={freq}): {e}; profiling unavailable"); + None + } }; }); } @@ -38,12 +41,17 @@ impl PprofHolder { // } pub fn flamegraph(&self) -> Result { - let holder = self.0.lock().unwrap(); + let holder = self + .0 + .lock() + .map_err(|e| anyhow::anyhow!("pprof lock poisoned: {e}"))?; if let Some(pp) = holder.as_ref() { let report = pp.report().build()?; let mut graph: Vec = vec![]; - report.flamegraph(&mut graph).unwrap(); + report + .flamegraph(&mut graph) + .map_err(|e| anyhow::anyhow!("pprof flamegraph write failed: {e}"))?; let graph = String::from_utf8(graph)?; Ok(graph) } else { diff --git a/probing/extensions/python/src/features/torch.rs b/probing/extensions/python/src/features/torch.rs index d0b8d60a..068a258b 100644 --- a/probing/extensions/python/src/features/torch.rs +++ b/probing/extensions/python/src/features/torch.rs @@ -13,8 +13,42 @@ struct Frame { module: String, } +const TORCH_QUERY: &str = r#" + select module, stage, median(CAST(duration AS DOUBLE)) + from python.torch_trace + where module <> 'None' + group by module, stage + order by (stage, module); +"#; + +/// Query torch profiling data. Prefer the global ENGINE (server's engine) so that +/// when PROBING_TORCH_PROFILING=on the flamegraph uses the same data as the UI. +fn query_profiling_impl() -> Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| anyhow::anyhow!("Failed to create runtime: {e}"))?; + + rt.block_on(async { + let engine = probing_core::ENGINE.read().await; + let result = engine + .async_query(TORCH_QUERY) + .await + .map_err(|e| anyhow::anyhow!("Torch query failed: {e}"))?; + Ok(result.unwrap_or_default()) + }) +} + pub fn query_profiling() -> Result> { let data = thread::spawn(|| -> Result { + // Use global ENGINE first (server's engine with python.torch_trace data) + match query_profiling_impl() { + Ok(df) => return Ok(df), + Err(e) => { + log::debug!("Global engine torch query failed ({e}), trying minimal engine"); + } + } + // Fallback: build a minimal engine (e.g. when not running inside server) let engine = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -25,42 +59,12 @@ pub fn query_profiling() -> Result> { .build() .await })?; - - let query = r#" - select module, stage, median(duration) - from python.torch_trace - where module <> 'None' - group by module, stage - order by (stage, module); - "#; - - // Check if we're already inside a tokio runtime to avoid nested runtime panic - match tokio::runtime::Handle::try_current() { - Ok(_handle) => { - // Inside a runtime, spawn a new thread - std::thread::spawn(move || { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(async { engine.async_query(query).await }) - }) - .join() - .map_err(|_| anyhow::anyhow!("Thread panicked"))? - .map_err(|e| anyhow::anyhow!(e))? - .ok_or_else(|| anyhow::anyhow!("Query returned no data")) - } - Err(_) => { - // Not in a runtime, create a new one - tokio::runtime::Builder::new_multi_thread() - .worker_threads(4) - .enable_all() - .build() - .unwrap() - .block_on(async { engine.async_query(query).await })? - .ok_or_else(|| anyhow::anyhow!("Query returned no data")) - } - } + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + Ok(rt.block_on(async { engine.async_query(TORCH_QUERY).await })? + .unwrap_or_default()) }) .join() .map_err(|_| anyhow::anyhow!("error joining thread"))??; diff --git a/probing/extensions/python/src/repl/console.rs b/probing/extensions/python/src/repl/console.rs index 65a35db5..75c3e299 100644 --- a/probing/extensions/python/src/repl/console.rs +++ b/probing/extensions/python/src/repl/console.rs @@ -1,38 +1,42 @@ use pyo3::ffi::c_str; use pyo3::{ types::{PyAnyMethods, PyDict}, - Bound, Py, PyAny, Python, + Py, PyAny, Python, }; use crate::repl::python_repl::PythonConsole; pub struct NativePythonConsole { - console: Py, + /// None if import or debug_console lookup failed (avoids panic in Default). + console: Option>, } impl Default for NativePythonConsole { #[inline(never)] fn default() -> Self { - Self { - console: Python::with_gil(|py| { - let global = PyDict::new(py); - let code = c_str!("from probing.repl import debug_console"); - let _ = py.run(code, Some(&global), Some(&global)); - let ret: Bound<'_, PyAny> = global - .get_item("debug_console") - .map_err(|err| { - eprintln!("error initializing console: {err}"); - }) - .unwrap(); - ret.unbind() - }), - } + let console = Python::with_gil(|py| { + let global = PyDict::new(py); + let code = c_str!("from probing.repl import debug_console"); + if py.run(code, Some(&global), Some(&global)).is_err() { + log::warn!("probing.repl import failed; REPL will be unavailable"); + return None; + } + match global.get_item("debug_console") { + Ok(ret) => Some(ret.unbind()), + Err(e) => { + log::warn!("error initializing console (debug_console not found or failed): {e}; REPL will be unavailable"); + None + } + } + }); + Self { console } } } impl PythonConsole for NativePythonConsole { fn try_execute(&mut self, cmd: String) -> Option { - Python::with_gil(|py| match self.console.call_method1(py, "push", (cmd,)) { + let console = self.console.as_ref()?; + Python::with_gil(|py| match console.call_method1(py, "push", (cmd,)) { Ok(obj) => { if obj.is_none(py) { None diff --git a/python/probing/ext/torch.py b/python/probing/ext/torch.py index 72e275c9..220ed1b9 100644 --- a/python/probing/ext/torch.py +++ b/python/probing/ext/torch.py @@ -65,7 +65,15 @@ def collective_hook(): trace_all_collectives(verbose=is_true(trace_verbose)) +_hook_registered = False + + def init(): + global _hook_registered + if _hook_registered: + return + _hook_registered = True + from torch.optim.optimizer import register_optimizer_step_post_hook register_optimizer_step_post_hook(optimizer_step_post_hook) diff --git a/python/probing/handlers/pythonext.py b/python/probing/handlers/pythonext.py index 3893d8f3..9e69bb2d 100644 --- a/python/probing/handlers/pythonext.py +++ b/python/probing/handlers/pythonext.py @@ -113,6 +113,8 @@ def get_chrome_tracing(limit: int = 1000) -> str: # Query trace events from the database # IMPORTANT: Order by timestamp ASC to process events in chronological order # This ensures span_start events are processed before their corresponding span_end events + if limit is None: + limit = 1000 limit_clause = f" LIMIT {limit}" if limit > 0 else "" query = f""" SELECT @@ -491,7 +493,10 @@ def start_trace( watch_list = [] silent_watch_list = watch or [] - trace(function, watch=watch_list, silent_watch=silent_watch_list, depth=depth) + depth_val = 1 if depth is None else depth + trace( + function, watch=watch_list, silent_watch=silent_watch_list, depth=depth_val + ) return json.dumps({"success": True, "message": f"Started tracing {function}"}) except Exception as e: return json.dumps({"success": False, "error": str(e)}) @@ -516,6 +521,33 @@ def stop_trace(function: str) -> str: return json.dumps({"success": False, "error": str(e)}) +@ext_handler( + "pythonext", + ["magics", "pythonext/magics", "python/magics"], +) +def get_magics_list() -> str: + """Get magic commands as JSON for UI quick actions. + + Returns: + JSON string: [{"group": "Trace", "items": [{"label": "...", "command": "..."}, ...]}, ...] + """ + try: + from probing.repl import debug_console + from probing.repl.help_magic import get_magics_for_ui + + if ( + debug_console + and getattr(debug_console, "code_executor", None) + and debug_console.code_executor + ): + shell = debug_console.code_executor.km.kernel.shell + result = get_magics_for_ui(shell) + return json.dumps(result) + return "[]" + except Exception as e: + return json.dumps({"error": str(e), "traceback": traceback.format_exc()}) + + @ext_handler("pythonext", "trace/variables") def get_trace_variables(function: Optional[str] = None, limit: int = 100) -> str: """Get trace variables from database. @@ -530,6 +562,8 @@ def get_trace_variables(function: Optional[str] = None, limit: int = 100) -> str try: import probing + if limit is None: + limit = 100 # Try with python namespace first, fallback to direct table name if function: queries = [ diff --git a/python/probing/inspect/torch.py b/python/probing/inspect/torch.py index 1d356ec7..cc7d945d 100644 --- a/python/probing/inspect/torch.py +++ b/python/probing/inspect/torch.py @@ -7,31 +7,43 @@ _last_full_refresh_time = 0 FULL_REFRESH_INTERVAL_SECONDS = 5 * 60 +# Limit number of objects to scan to avoid DoS in large processes +MAX_OBJECTS_TO_SCAN = 200_000 def update_cache(x): + """Add a single object to the appropriate cache if it is a Tensor/Module/Optimizer.""" import torch - idx = id(x) - if isinstance(x, torch.Tensor): - if idx not in tensor_cache: - tensor_cache[idx] = weakref.ref(x) - return tensor_cache[idx] - if isinstance(x, torch.nn.Module): - if idx not in module_cache: - module_cache[idx] = weakref.ref(x) - return module_cache[idx] - if isinstance(x, torch.optim.Optimizer): - if idx not in optim_cache: - optim_cache[idx] = weakref.ref(x) - return optim_cache[idx] + try: + idx = id(x) + if isinstance(x, torch.Tensor): + if idx not in tensor_cache: + tensor_cache[idx] = weakref.ref(x) + return tensor_cache[idx] + if isinstance(x, torch.nn.Module): + if idx not in module_cache: + module_cache[idx] = weakref.ref(x) + return module_cache[idx] + if isinstance(x, torch.optim.Optimizer): + if idx not in optim_cache: + optim_cache[idx] = weakref.ref(x) + return optim_cache[idx] + except (ReferenceError, TypeError, AttributeError, RuntimeError): + pass + return None def refresh_cache(): import gc - for obj in gc.get_objects(): - update_cache(obj) + objects = gc.get_objects() + n = min(len(objects), MAX_OBJECTS_TO_SCAN) if MAX_OBJECTS_TO_SCAN else len(objects) + for i in range(n): + try: + update_cache(objects[i]) + except (ReferenceError, TypeError, AttributeError, RuntimeError): + continue global _last_full_refresh_time _last_full_refresh_time = time.time() diff --git a/python/probing/inspect/trace.py b/python/probing/inspect/trace.py index c6fe61a3..f23d169d 100644 --- a/python/probing/inspect/trace.py +++ b/python/probing/inspect/trace.py @@ -4,6 +4,7 @@ import inspect import json import os +import re import sys import threading import time @@ -25,6 +26,54 @@ # Global dictionary to store module references needed by wrapper functions _probe_modules = {"sys": sys} +# --- Security limits (input validation and DoS mitigation) --- +MAX_TRACE_DEPTH = 20 +MAX_LIST_TRACEABLE_DEPTH = 10 +MAX_PREFIX_LENGTH = 512 +MAX_VARIABLE_VALUE_LENGTH = 8192 +# Top-level module names that must not be traceable via trace()/untrace() (e.g. from API) +TRACE_BLOCKLIST_TOPLEVEL = frozenset( + { + "os", + "sys", + "subprocess", + "builtins", + "importlib", + "ctypes", + "socket", + "ssl", + "code", + "codeop", + "compiler", + "dis", + "ast", + "pickle", + "marshal", + "copyreg", + "runpy", + "posix", + "nt", + "errno", + } +) +# Pattern: only allow dotted identifiers (letters, digits, underscore, dot) +TRACE_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_.]*$") + + +def _validate_trace_name(name: str) -> None: + """Raise ValueError if name is not allowed for trace/untrace (security).""" + if not name or not isinstance(name, str): + raise ValueError("Function name must be a non-empty string") + if len(name) > MAX_PREFIX_LENGTH: + raise ValueError(f"Function name length must be at most {MAX_PREFIX_LENGTH}") + if not TRACE_NAME_PATTERN.match(name): + raise ValueError( + "Function name may only contain letters, digits, underscores and dots" + ) + top = name.split(".", 1)[0] + if top in TRACE_BLOCKLIST_TOPLEVEL: + raise ValueError(f"Tracing is not allowed for module: {top}") + @table("trace_variables") @dataclass @@ -664,14 +713,15 @@ def wrapper(*args, **kwargs): class ProbingTracer: - def __init__(self, depth=1, watch=[], silent_watch=[]): + def __init__(self, depth=1, watch=None, silent_watch=None): self.depth = depth self.count_calls = 0 self.count_returns = 0 - self.watch = watch # Variables to watch and print - self.silent_watch = silent_watch # Variables to watch but only log to table - # Combined list of all watched variables - self.all_watch = list(set(watch + silent_watch)) + watch = watch if watch is not None else [] + silent_watch = silent_watch if silent_watch is not None else [] + self.watch = list(watch) + self.silent_watch = list(silent_watch) + self.all_watch = list(set(self.watch + self.silent_watch)) self.watch_impl = {} def on_call(self): @@ -753,6 +803,11 @@ def trace(self, frame: FrameType, event: AnyStr, arg: Any): else frame.f_code.co_filename ) value_str = str(new_value) + if len(value_str) > MAX_VARIABLE_VALUE_LENGTH: + value_str = ( + value_str[:MAX_VARIABLE_VALUE_LENGTH] + + f"... (truncated, total {len(value_str)} chars)" + ) value_type = type(new_value).__name__ # Print only if variable is in watch list (not silent_watch) @@ -863,6 +918,14 @@ def list_traceable(prefix=None, depth=2): >>> list_traceable("torch.nn") # doctest: +SKIP >>> list_traceable("torch.*.Linear") # doctest: +SKIP """ + if prefix is not None: + if not isinstance(prefix, str) or len(prefix) > MAX_PREFIX_LENGTH: + raise ValueError( + f"prefix must be a string of length at most {MAX_PREFIX_LENGTH}" + ) + if depth is None: + depth = 2 + depth = max(0, min(int(depth), MAX_LIST_TRACEABLE_DEPTH)) collector = _TraceableCollector() filter_func = collector.create_filter(prefix) traceable_items = collector.collect_traceable_items(depth, filter_func) @@ -895,7 +958,13 @@ def getname(obj): return _TraceableCollector.get_object_name(obj) -def trace(func_or_name, watch=[], silent_watch=[], depth=1, callback=None): +def trace( + func_or_name, + watch=None, + silent_watch=None, + depth=1, + callback=None, +): def get_func(name): names = name.split(".") parent = sys.modules.get(names[0], None) @@ -909,7 +978,16 @@ def get_func(name): else: raise ValueError(f"{names[0]} not found in {parent}.") + if watch is None: + watch = [] + if silent_watch is None: + silent_watch = [] + if isinstance(func_or_name, str): + _validate_trace_name(func_or_name) + if depth is None: + depth = 1 + depth = max(0, min(int(depth), MAX_TRACE_DEPTH)) if func_or_name in traced_functions: print(f"Function {func_or_name} is already being traced.") return @@ -1033,6 +1111,7 @@ def get_func(name): raise ValueError(f"{names[0]} not found in {parent}.") if isinstance(func_or_name, str): + _validate_trace_name(func_or_name) if func_or_name not in traced_functions: print(f"Function {func_or_name} is not being traced.") return diff --git a/python/probing/profiling/torch/module_utils.py b/python/probing/profiling/torch/module_utils.py index 3b1de9bc..caf51998 100644 --- a/python/probing/profiling/torch/module_utils.py +++ b/python/probing/profiling/torch/module_utils.py @@ -39,6 +39,9 @@ def wrapper(*args, **kwargs): def module_analysis(m, prefix=""): if not isinstance(m, torch.nn.Module): return + # Register root module name (e.g. "AlexNet", "features", "classifier") + root_name = module_get_fullname(m).split(".")[-1] if prefix == "" else prefix + module_name(m, root_name) for n, s in m.named_children(): name = f"{prefix}.{n}" if prefix != "" else n module_name(s, name) diff --git a/python/probing/profiling/torch_probe.py b/python/probing/profiling/torch_probe.py index 223f1478..7d12637a 100644 --- a/python/probing/profiling/torch_probe.py +++ b/python/probing/profiling/torch_probe.py @@ -42,7 +42,6 @@ class TorchTrace: max_cached: float = 0.0 time_offset: float = 0.0 duration: float = 0.0 - duration: float = 0.0 @table @@ -209,6 +208,23 @@ def configure(spec: Optional[str] = None) -> TorchProbeConfig: probing.config.remove(_CONFIG_KEY) config = TorchProbeConfig.parse(spec) + + # Register the optimizer hook when profiling is enabled. This is required because + # the hook is normally registered via the import-hook when "torch" is first + # imported, but that may not run (e.g. when probing is embedded). Calling + # ext.torch.init() ensures the optimizer step hook is always registered. + if config.enabled: + try: + from probing.ext.torch import init as torch_ext_init + + torch_ext_init() + except ImportError as e: + import logging + + logging.getLogger(__name__).warning( + "Torch profiling enabled but ext.torch init failed: %s", e + ) + return config @@ -269,12 +285,35 @@ def mem_stats() -> TorchTrace: } +def _backend_has_event(): + """Check if the backend supports Event for GPU timing (CUDA yes, MPS in PyTorch 2.0+).""" + if backend is None: + return False + # CUDA: torch.cuda.Event; MPS: torch.mps.event.Event + return hasattr(backend, "Event") or ( + hasattr(backend, "event") and hasattr(backend.event, "Event") + ) + + +def _backend_event(enable_timing=True): + """Create a backend Event. CUDA: Event; MPS: event.Event.""" + if backend is None: + return None + if hasattr(backend, "event") and hasattr(backend.event, "Event"): + return backend.event.Event(enable_timing=enable_timing) + if hasattr(backend, "Event"): + return backend.Event(enable_timing=enable_timing) + return None + + class Timer: def __init__(self, sync: bool = False, **kwargs): self.has_backend = backend is not None + self.use_gpu_events = _backend_has_event() self.sync = sync self.events = {} # GPU timers + self.cpu_start = {} # CPU fallback: {key: start_time} self.step_start = None super().__init__(**kwargs) @@ -290,11 +329,21 @@ def begin_timing(self, mod, stage) -> float: else: time_offset = time.time() - self.step_start - if self.has_backend: - key = (id(mod), STAGEMAP[stage]) - event = backend.Event(enable_timing=True) - event.record() - self.events[key] = event + key = (id(mod), STAGEMAP[stage]) + if self.use_gpu_events: + try: + event = _backend_event(enable_timing=True) + if event is not None: + event.record() + self.events[key] = event + else: + self.use_gpu_events = False + self.cpu_start[key] = time.time() + except (AttributeError, TypeError): + self.use_gpu_events = False + self.cpu_start[key] = time.time() + else: + self.cpu_start[key] = time.time() return time_offset def end_timing(self, mod, stage) -> tuple: @@ -306,9 +355,27 @@ def end_timing(self, mod, stage) -> tuple: key = (id(mod), STAGEMAP[stage]) if key in self.events: - end_event = backend.Event(enable_timing=True) - end_event.record() - return time_offset, (self.events.pop(key), end_event) + try: + end_event = _backend_event(enable_timing=True) + if end_event is not None: + end_event.record() + return time_offset, (self.events.pop(key), end_event) + except (AttributeError, TypeError): + pass + self.events.pop(key, None) + if key in self.cpu_start: + duration_sec = time.time() - self.cpu_start.pop(key) + + # CPU fallback: use a simple (start, end) tuple; DelayedRecord checks events + # Create a minimal object that provides elapsed_time for compatibility + class _CpuTime: + def __init__(self, duration_ms): + self._duration_ms = duration_ms + + def elapsed_time(self, _other): + return self._duration_ms + + return time_offset, (_CpuTime(duration_sec * 1000), None) return time_offset, None diff --git a/python/probing/repl/help_magic.py b/python/probing/repl/help_magic.py index ed891fa2..19124303 100644 --- a/python/probing/repl/help_magic.py +++ b/python/probing/repl/help_magic.py @@ -2,41 +2,190 @@ This module provides a help system that uses introspection to automatically discover all registered magic commands. + +All metadata (label, command, help) is derived from reflection: +- @magic_arguments / @argument decorators: primary source for magics using them +- Docstring Usage section: fallback for subcommand syntax and inline help +- Subcommand handler methods: _cmd_{magic}_{subcmd} or _cmd_{subcmd} docstrings +- Magic class/function docstring: overall description """ +import re +from typing import Any, Dict, List, Optional, Tuple + from IPython.core.magic import Magics, line_magic, magics_class from probing.repl import register_magic -@register_magic("cmds") -@magics_class -class HelpMagic(Magics): - """Magic commands for help and documentation.""" - - @line_magic - def cmds(self, line: str): - """List all available magic commands using introspection. - - Usage: - %cmds # Show probing magic commands - %cmds --all # Include IPython built-in magics - - For detailed help on a specific command, use: %command? - """ - show_all = "--all" in line or "-a" in line - - # Get all registered magics from the shell - line_magics = self.shell.magics_manager.magics.get("line", {}) - cell_magics = self.shell.magics_manager.magics.get("cell", {}) - - # Group magics by their class - magic_groups = {} - - # Process line magics - for name, func in line_magics.items(): +def _parse_choices_from_help(help_str: Optional[str]) -> List[str]: + """Extract choice tokens from argument help, e.g. 'Subcommand: ls/list, gc, cuda' -> ['ls','list','gc','cuda'].""" + if not help_str or not help_str.strip(): + return [] + text = help_str + if ":" in text: + text = text.split(":", 1)[1].strip() + parts: List[str] = [] + for segment in re.split(r",|\s+or\s+", text, flags=re.I): + segment = segment.strip() + if not segment: + continue + if "/" in segment: + for alt in segment.split("/"): + a = alt.strip() + if a and a not in parts: + parts.append(a) + else: + if segment not in parts: + parts.append(segment) + return parts + + +def _introspect_magic_arguments(func: Any) -> Optional[List[Tuple[str, str]]]: + """Extract subcommands from @magic_arguments / @argument decorator metadata. + + Returns list of (command_suffix, help_text) e.g. [('ls modules', '...'), ('gc', '...')], + or None if func has no parser. + """ + parser = getattr(func, "parser", None) + if parser is None or not hasattr(parser, "_actions"): + return None + + positionals: List[Tuple[str, str]] = [] + for action in parser._actions: + if action.dest == "help": + continue + if getattr(action, "option_strings", None): + continue + dest = getattr(action, "dest", None) + help_str = getattr(action, "help", None) or "" + if dest: + positionals.append((dest, help_str)) + + if not positionals: + return [("", getattr(parser, "description", None) or "")] + + choices_per_pos: List[List[str]] = [] + help_per_pos: List[str] = [] + for dest, help_str in positionals: + choices = _parse_choices_from_help(help_str) + choices_per_pos.append(choices if choices else [""]) + help_per_pos.append(help_str) + + if len(positionals) == 1: + if choices_per_pos[0]: + return [ + (c, help_per_pos[0]) for c in choices_per_pos[0] if c and c != "help" + ] + return [("", help_per_pos[0])] + + subcmd_choices = choices_per_pos[0] + target_choices = choices_per_pos[1] if len(choices_per_pos) > 1 else [] + + excludes = {"help"} + subcmd_choices = [c for c in subcmd_choices if c and c not in excludes] + + result: List[Tuple[str, str]] = [] + sub_added: set = set() + for sub in subcmd_choices: + cmd_sub = "ls" if sub == "list" else sub + if target_choices and sub in ("ls", "list"): + if "ls" in sub_added: + continue + sub_added.add("ls") + for t in target_choices: + if t: + result.append((f"ls {t}", help_per_pos[1] or help_per_pos[0])) + else: + if cmd_sub in sub_added: + continue + sub_added.add(cmd_sub) + result.append((cmd_sub, help_per_pos[0])) + return result if result else None + + +def _split_subcmd_help(line: str) -> Tuple[str, str]: + """Split 'watch Show currently watched' into ('watch', 'Show currently watched').""" + m = re.match(r"^(.+?)\s{2,}(.+)$", line) + if m: + return m.group(1).strip(), m.group(2).strip() + return line.strip(), "" + + +def _extract_invokable(syntax: str) -> str: + """Extract runnable part from syntax, stripping optional args. + 'list [] [--limit ]' -> 'list' + 'ls modules --all' -> 'ls modules' + 'profile [steps=N]' -> 'profile' + """ + tokens = [] + for tok in syntax.split(): + if tok.startswith("[") or tok.startswith("<") or tok.startswith("--"): + break + tokens.append(tok) + return " ".join(tokens) if tokens else syntax.split()[0] if syntax.split() else "" + + +def _get_subcommand_method_doc( + magic_obj: Any, magic_name: str, subcmd_base: str +) -> str: + """Try to get docstring from subcommand handler via reflection.""" + if not subcmd_base: + return "" + subcmd_underscore = subcmd_base.replace(" ", "_").replace("-", "_") + first_word = subcmd_base.split()[0] if subcmd_base else "" + for method_name in ( + f"_cmd_{magic_name}_{subcmd_underscore}", + f"_cmd_{magic_name}_{first_word}", + f"_cmd_{subcmd_underscore}", + f"_handle_{first_word}", + f"_handle_{subcmd_underscore}", + ): + if method_name and hasattr(magic_obj, method_name): + m = getattr(magic_obj, method_name) + if callable(m) and m.__doc__: + first_line = m.__doc__.strip().split("\n")[0] + return first_line.strip() + return "" + + +def _discover_subcommands_from_class( + magic_obj: Any, magic_name: str +) -> List[Tuple[str, str]]: + """Discover subcommands by reflecting on magic class methods. + Finds _cmd_{name}_{subcmd}, _handle_{subcmd} etc. and derives (syntax, help) from docstrings. + """ + result: List[Tuple[str, str]] = [] + seen: set = set() + for attr in dir(magic_obj): + if not attr.startswith("_") or attr.startswith("__"): + continue + subcmd = None + if attr.startswith(f"_cmd_{magic_name}_"): + subcmd = attr[len(f"_cmd_{magic_name}_") :].replace("_", " ") + elif attr.startswith("_handle_"): + subcmd = attr[len("_handle_") :].replace("_", " ") + elif attr.startswith("_cmd_") and magic_name not in attr: + subcmd = attr[len("_cmd_") :].replace("_", " ") + if subcmd and subcmd not in seen: + seen.add(subcmd) + m = getattr(magic_obj, attr) + help_txt = (m.__doc__ or "").strip().split("\n")[0] if callable(m) else "" + result.append((subcmd, help_txt)) + return result + + +def _build_magic_groups( + shell: Any, show_all: bool = False +) -> Dict[str, Dict[str, List]]: + """Build magic_groups dict from shell introspection. Shared by cmds() and get_magics_for_ui().""" + line_magics = shell.magics_manager.magics.get("line", {}) + cell_magics = shell.magics_manager.magics.get("cell", {}) + magic_groups: Dict[str, Dict[str, List]] = {} + + def process_magics(magics_dict: dict, magic_type: str) -> None: + for name, func in magics_dict.items(): try: - # Handle MagicAlias and bound methods if hasattr(func, "__self__"): magic_obj = func.__self__ elif hasattr(func, "obj"): @@ -44,7 +193,6 @@ def cmds(self, line: str): else: continue - # Filter probing magics by module path module = magic_obj.__class__.__module__ if not show_all and "probing" not in module: continue @@ -53,188 +201,195 @@ def cmds(self, line: str): if class_name not in magic_groups: magic_groups[class_name] = {"line": [], "cell": []} - # Extract description and subcommands from docstring - doc = func.__doc__ or "No description" - description = "No description" - subcommands = [] - - # Parse docstring to extract description and subcommands - in_usage = False - for doc_line in doc.strip().split("\n"): - doc_line = doc_line.strip() - - # Detect Usage section - if doc_line.startswith("Usage:"): - in_usage = True - continue - - # Extract subcommands from Usage section - if in_usage: - # Stop at Examples or other sections - if doc_line.startswith("Examples:") or doc_line.startswith( - "Subcommands:" - ): - in_usage = False - continue - - # Skip empty lines (but continue in usage mode if we have subcommands) - if not doc_line: - if subcommands: - in_usage = False # End of usage section - continue + subcommands: List[Tuple[str, str]] = [] + parser_subcmds = _introspect_magic_arguments(func) + if parser_subcmds: + subcommands = parser_subcmds + else: + subcommands = [] - # Skip comment-only lines - if doc_line.startswith("#"): + doc = func.__doc__ or "No description" + if parser_subcmds: + parser = getattr(func, "parser", None) + description = ( + (getattr(parser, "description", None) or "").strip() + or doc.split("\n")[0].strip() + or "No description" + ) + else: + description = "No description" + in_usage = False + for doc_line in doc.strip().split("\n"): + doc_line = doc_line.strip() + if doc_line.startswith("Usage:"): + in_usage = True continue - - # Check if this line contains the command name - cmd_patterns = [f"%{name}", f"%%{name}", name] - subcmd_line = None - - for pattern in cmd_patterns: - if pattern in doc_line: - # Extract everything after the command name - parts = doc_line.split(pattern, 1) - if len(parts) > 1: - subcmd_line = parts[1].strip() - # Remove inline comments (everything after #) - if "#" in subcmd_line: - subcmd_line = subcmd_line.split("#", 1)[ - 0 - ].strip() - break - - # If no command pattern found, but line looks like a subcommand (starts with common subcommands) - if ( - not subcmd_line - and doc_line - and not doc_line.startswith(" ") - ): - # Might be a subcommand line without the main command prefix - # Check if it looks like a subcommand (has common patterns) - if any( - word in doc_line.lower() - for word in [ - "watch", - "list", - "profile", - "summary", - "timeline", - "ls", - "gc", - "cuda", - ] + if in_usage: + if doc_line.startswith("Examples:") or doc_line.startswith( + "Subcommands:" ): - # Remove comments - subcmd_line = doc_line.split("#", 1)[0].strip() - - if subcmd_line: - subcommands.append(subcmd_line) - else: - # Extract first non-empty, non-usage, non-:: line as description - if ( - doc_line - and not doc_line.startswith("Usage:") - and doc_line != "::" - and not doc_line.startswith("%") - and description == "No description" - ): - description = doc_line - - magic_groups[class_name]["line"].append( - (name, description, subcommands) + in_usage = False + continue + if not doc_line: + if subcommands: + in_usage = False + continue + if doc_line.startswith("#"): + continue + + cmd_patterns = [f"%{name}", f"%%{name}", name] + subcmd_line = None + for pattern in cmd_patterns: + if pattern in doc_line: + parts = doc_line.split(pattern, 1) + if len(parts) > 1: + subcmd_line = parts[1].strip() + if "#" in subcmd_line: + subcmd_line = subcmd_line.split("#", 1)[ + 0 + ].strip() + break + + if subcmd_line: + syntax, help_text = _split_subcmd_help(subcmd_line) + subcommands.append((syntax, help_text)) + else: + if ( + doc_line + and not doc_line.startswith("Usage:") + and doc_line != "::" + and not doc_line.startswith("%") + and description == "No description" + ): + description = doc_line + + magic_groups[class_name][magic_type].append( + (name, description, subcommands, magic_obj) ) except (AttributeError, KeyError): - # Skip magics that can't be introspected pass - # Process cell magics - for name, func in cell_magics.items(): - try: - # Handle MagicAlias and bound methods - if hasattr(func, "__self__"): - magic_obj = func.__self__ - elif hasattr(func, "obj"): - magic_obj = func.obj - else: + process_magics(line_magics, "line") + process_magics(cell_magics, "cell") + return magic_groups + + +def _label_from_help(help_text: str, fallback_base: str) -> str: + """Derive UI label from help text or subcommand name.""" + if help_text: + # Use first ~30 chars of help, or first sentence + first = help_text.split(".")[0].strip() + if len(first) <= 35: + return first + return first[:32].rsplit(" ", 1)[0] + "..." + return fallback_base.replace("_", " ").replace("-", " ").title() + + +def get_magics_for_ui(shell: Any) -> List[Dict[str, Any]]: + """Return magics as JSON-serializable list for UI quick actions. + + All data is derived from introspection (docstrings, method reflection). + Returns: + [{"group": "Trace", "items": [{"label": "...", "command": "%trace list", "help": "..."}, ...]}, ...] + """ + magic_groups = _build_magic_groups(shell, show_all=False) + result: List[Dict[str, Any]] = [] + + for class_name in sorted(magic_groups.keys()): + group = magic_groups[class_name] + display_name = class_name.replace("Magic", "") + items: List[Dict[str, str]] = [] + seen_commands: set = set() + + for item in group["line"]: + if len(item) >= 4: + name, _desc, subcommands, magic_obj = item[0], item[1], item[2], item[3] + elif len(item) == 3: + name, _desc, subcommands = item + magic_obj = None + else: + name, _desc = item[:2] + subcommands = [] + magic_obj = None + + def add_item(label: str, command: str, help_text: str) -> None: + if command and command not in seen_commands: + seen_commands.add(command) + items.append( + {"label": label, "command": command, "help": help_text or ""} + ) + + prefix = f"%{name} " + if not subcommands and magic_obj: + subcommands = _discover_subcommands_from_class(magic_obj, name) + for subcmd_entry in subcommands: + subcmd_raw = ( + subcmd_entry[0] if isinstance(subcmd_entry, tuple) else subcmd_entry + ) + docstring_help = ( + subcmd_entry[1] + if isinstance(subcmd_entry, tuple) and len(subcmd_entry) > 1 + else "" + ) + subcmd_clean = ( + subcmd_raw.strip() + if isinstance(subcmd_raw, str) + else str(subcmd_raw) + ) + if subcmd_clean.startswith("<"): continue - module = magic_obj.__class__.__module__ - if not show_all and "probing" not in module: + invokable = _extract_invokable(subcmd_clean) + if not invokable: + if subcmd_clean == "" and docstring_help: + command = f"%{name}" + help_txt = docstring_help + label = _label_from_help(help_txt, display_name) + add_item(label, command, help_txt) continue + base = invokable.split()[0] if invokable else subcmd_clean.split()[0] - class_name = magic_obj.__class__.__name__ - if class_name not in magic_groups: - magic_groups[class_name] = {"line": [], "cell": []} + command = f"{prefix}{invokable}" + help_txt = ( + _get_subcommand_method_doc(magic_obj, name, invokable) + if magic_obj + else "" + ) + if not help_txt: + help_txt = docstring_help + label = _label_from_help(help_txt, invokable.replace("_", " ")) + add_item(label, command, help_txt) - doc = func.__doc__ or "No description" - description = "No description" - subcommands = [] + # Magics with no subcommands (e.g. %tables, %cmds) + if not subcommands: + command = f"%{name}" + help_txt = _desc if _desc and _desc != "No description" else "" + label = _label_from_help(help_txt, display_name) + add_item(label, command, help_txt) - # Parse docstring to extract description and subcommands - in_usage = False - for doc_line in doc.strip().split("\n"): - doc_line = doc_line.strip() - - # Detect Usage section - if doc_line.startswith("Usage:"): - in_usage = True - continue - - # Extract subcommands from Usage section - if in_usage: - # Stop at Examples or other sections - if doc_line.startswith("Examples:") or doc_line.startswith( - "Subcommands:" - ): - in_usage = False - continue + if items: + result.append({"group": display_name, "items": items}) - # Skip empty lines - if not doc_line: - if subcommands: - in_usage = False - continue + return result - # Skip comment-only lines - if doc_line.startswith("#"): - continue - # Check if this line contains the command name - cmd_patterns = [f"%%{name}", f"%{name}", name] - subcmd_line = None - - for pattern in cmd_patterns: - if pattern in doc_line: - parts = doc_line.split(pattern, 1) - if len(parts) > 1: - subcmd_line = parts[1].strip() - # Remove inline comments - if "#" in subcmd_line: - subcmd_line = subcmd_line.split("#", 1)[ - 0 - ].strip() - break - - if subcmd_line: - subcommands.append(subcmd_line) - else: - # Extract first non-empty line as description - if ( - doc_line - and not doc_line.startswith("Usage:") - and doc_line != "::" - and not doc_line.startswith("%") - and description == "No description" - ): - description = doc_line - - magic_groups[class_name]["cell"].append( - (name, description, subcommands) - ) - except (AttributeError, KeyError): - # Skip magics that can't be introspected - pass +@register_magic("cmds") +@magics_class +class HelpMagic(Magics): + """Magic commands for help and documentation.""" + + @line_magic + def cmds(self, line: str): + """List all available magic commands using introspection. + + Usage: + %cmds # Show probing magic commands + %cmds --all # Include IPython built-in magics + + For detailed help on a specific command, use: %command? + """ + show_all = "--all" in line or "-a" in line + magic_groups = _build_magic_groups(self.shell, show_all=show_all) # Build output title = "🔮 Probing Magic Commands" if not show_all else "🔮 All Magic Commands" @@ -249,11 +404,10 @@ def cmds(self, line: str): output.append("-" * 70) # Show line magics - for item in sorted(group["line"]): - if len(item) == 3: - name, desc, subcommands = item + for item in sorted(group["line"], key=lambda x: x[0] if x else ""): + if len(item) >= 3: + name, desc, subcommands = item[0], item[1], item[2] else: - # Backward compatibility name, desc = item[:2] subcommands = [] @@ -264,24 +418,32 @@ def cmds(self, line: str): # Show subcommands if available if subcommands: for subcmd in subcommands[:5]: # Limit to 5 subcommands - # Clean up subcommand line - subcmd_clean = subcmd.strip() - # Remove leading # if present - if subcmd_clean.startswith("#"): - subcmd_clean = subcmd_clean[1:].strip() - # Truncate if too long - if len(subcmd_clean) > 60: - subcmd_clean = subcmd_clean[:57] + "..." - output.append(f" └─ %{name} {subcmd_clean}") + syntax = subcmd[0] if isinstance(subcmd, tuple) else subcmd + help_txt = ( + subcmd[1] + if isinstance(subcmd, tuple) and len(subcmd) > 1 + else "" + ) + syntax = ( + syntax.strip() if isinstance(syntax, str) else str(syntax) + ) + if syntax.startswith("#"): + syntax = syntax[1:].strip() + if len(syntax) > 60: + syntax = syntax[:57] + "..." + line = f" └─ %{name} {syntax}" + if help_txt: + line += f" # {help_txt[:40]}{'...' if len(help_txt) > 40 else ''}" + output.append(line) if len(subcommands) > 5: output.append( f" └─ ... and {len(subcommands) - 5} more (use %{name}? for full help)" ) # Show cell magics - for item in sorted(group["cell"]): - if len(item) == 3: - name, desc, subcommands = item + for item in sorted(group["cell"], key=lambda x: x[0] if x else ""): + if len(item) >= 3: + name, desc, subcommands = item[0], item[1], item[2] else: name, desc = item[:2] subcommands = [] @@ -292,12 +454,23 @@ def cmds(self, line: str): # Show subcommands if available if subcommands: for subcmd in subcommands[:5]: - subcmd_clean = subcmd.strip() - if subcmd_clean.startswith("#"): - subcmd_clean = subcmd_clean[1:].strip() - if len(subcmd_clean) > 60: - subcmd_clean = subcmd_clean[:57] + "..." - output.append(f" └─ %%{name} {subcmd_clean}") + syntax = subcmd[0] if isinstance(subcmd, tuple) else subcmd + help_txt = ( + subcmd[1] + if isinstance(subcmd, tuple) and len(subcmd) > 1 + else "" + ) + syntax = ( + syntax.strip() if isinstance(syntax, str) else str(syntax) + ) + if syntax.startswith("#"): + syntax = syntax[1:].strip() + if len(syntax) > 60: + syntax = syntax[:57] + "..." + line = f" └─ %%{name} {syntax}" + if help_txt: + line += f" # {help_txt[:40]}{'...' if len(help_txt) > 40 else ''}" + output.append(line) if len(subcommands) > 5: output.append( f" └─ ... and {len(subcommands) - 5} more (use %%{name}? for full help)" diff --git a/web/src/api/mod.rs b/web/src/api/mod.rs index 4a6eab47..687d276e 100644 --- a/web/src/api/mod.rs +++ b/web/src/api/mod.rs @@ -65,6 +65,7 @@ mod cluster; mod dashboard; mod profiling; mod pytorch; +mod repl; mod stack; mod trace; mod traces; @@ -80,6 +81,8 @@ pub use profiling::*; #[allow(unused_imports)] pub use pytorch::*; #[allow(unused_imports)] +pub use repl::*; +#[allow(unused_imports)] pub use stack::*; #[allow(unused_imports)] pub use trace::*; diff --git a/web/src/api/repl.rs b/web/src/api/repl.rs new file mode 100644 index 00000000..4648a4a5 --- /dev/null +++ b/web/src/api/repl.rs @@ -0,0 +1,61 @@ +use super::ApiClient; +use crate::utils::error::Result; +use serde::{Deserialize, Serialize}; + +/// Magic quick action item for UI +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MagicItem { + pub label: String, + pub command: String, + #[serde(default)] + pub help: String, +} + +/// Magic group for UI dropdown +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MagicGroup { + pub group: String, + pub items: Vec, +} + +/// Eval execution result (matches Python ExecutionResult / debug_console.push output) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EvalResponse { + pub status: String, + #[serde(default)] + pub output: String, + #[serde(default)] + pub traceback: Vec, +} + +/// REPL / Magic command API +impl ApiClient { + /// Get magic commands as structured list for UI quick actions. + pub async fn get_magics(&self) -> Result> { + let path = "/apis/pythonext/magics"; + let text = self.get_request(path).await?; + serde_json::from_str(&text).map_err(|e| { + crate::utils::error::AppError::Api(format!("JSON parse error: {e}")) + }) + } + + /// Execute Python code or magic command in the target process REPL. + /// Returns the execution result (output, status, traceback). + pub async fn eval(&self, code: &str) -> Result { + let text = self + .post_request_with_body("/apis/pythonext/eval", code.to_string()) + .await?; + + // Response may be JSON (from REPL) or plain text (e.g. panic recovery) + if let Ok(json) = serde_json::from_str::(&text) { + return Ok(json); + } + + // Fallback: treat as output + Ok(EvalResponse { + status: "ok".to_string(), + output: text, + traceback: vec![], + }) + } +} diff --git a/web/src/api/trace.rs b/web/src/api/trace.rs index 6bc89ae7..6b0f86f5 100644 --- a/web/src/api/trace.rs +++ b/web/src/api/trace.rs @@ -107,11 +107,11 @@ impl ApiClient { print_to_terminal: bool, ) -> Result { let base = "/apis/pythonext/trace/start"; - let mut params = vec![format!("function={}", function)]; + let mut params = vec![format!("function={}", urlencoding::encode(function))]; if let Some(watch) = watch { if !watch.is_empty() { - params.push(format!("watch={}", watch.join(","))); + params.push(format!("watch={}", urlencoding::encode(&watch.join(",")))); } } @@ -119,11 +119,7 @@ impl ApiClient { params.push("print_to_terminal=true".to_string()); } - let path = if params.len() > 1 { - format!("{}?{}", base, params.join("&")) - } else { - format!("{}?function={}", base, function) - }; + let path = format!("{}?{}", base, params.join("&")); let response = self.get_request(&path).await?; let result: TraceResponse = Self::parse_json(&response)?; @@ -132,7 +128,10 @@ impl ApiClient { /// Stop tracing a function pub async fn stop_trace(&self, function: &str) -> Result { - let path = format!("/apis/pythonext/trace/stop?function={}", function); + let path = format!( + "/apis/pythonext/trace/stop?function={}", + urlencoding::encode(function) + ); let response = self.get_request(&path).await?; let result: TraceResponse = Self::parse_json(&response)?; Ok(result) diff --git a/web/src/components/global_command_panel.rs b/web/src/components/global_command_panel.rs new file mode 100644 index 00000000..b1386ed7 --- /dev/null +++ b/web/src/components/global_command_panel.rs @@ -0,0 +1,373 @@ +//! Global Command Panel (VS Code style) and floating result. +//! +//! Open via Commands button in command bar. Select command to fill input (↑↓ + Enter). +//! Edit in input bar, then Run to execute. + +use dioxus::prelude::*; + +use crate::api::{ApiClient, MagicGroup, MagicItem}; +use crate::components::colors::colors; +use crate::hooks::use_api; +use crate::state::commands::{ + Cell, EvalState, FloatingResult, COMMAND_INPUT, COMMAND_PANEL_OPEN, EVAL_HISTORY, +}; + +/// Flatten groups into searchable items +fn flatten_magics(groups: &[MagicGroup]) -> Vec<(String, MagicItem)> { + groups + .iter() + .flat_map(|g: &MagicGroup| { + g.items + .iter() + .map(move |i: &MagicItem| (g.group.clone(), i.clone())) + }) + .collect() +} + +/// Filter by query +fn filter_magics(items: &[(String, MagicItem)], query: &str) -> Vec<(String, MagicItem)> { + let q = query.to_lowercase(); + if q.is_empty() { + return items.to_vec(); + } + items + .iter() + .filter(|(_, item)| { + item.command.to_lowercase().contains(&q) + || item.label.to_lowercase().contains(&q) + || item.help.to_lowercase().contains(&q) + }) + .cloned() + .collect() +} + +#[component] +fn CommandPanelItem( + cmd: String, + help: String, + group: String, + is_selected: bool, + on_select: EventHandler, +) -> Element { + let cmd_clone = cmd.clone(); + rsx! { + button { + class: if is_selected { + "w-full text-left px-4 py-2 bg-blue-50 border-l-2 border-blue-600 flex flex-col gap-0.5" + } else { + "w-full text-left px-4 py-2 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 flex flex-col gap-0.5 border-l-2 border-transparent" + }, + onclick: move |_| on_select.call(cmd_clone.clone()), + div { + class: "flex items-center gap-2", + span { class: "text-sm font-mono font-medium text-gray-800", "{cmd}" } + span { class: "text-xs text-gray-400", "{group}" } + } + if !help.is_empty() { + div { class: "text-xs text-gray-500 truncate", "{help}" } + } + } + } +} + +#[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. +#[component] +pub fn GlobalCommandPanel() -> Element { + let mut panel_query = use_signal(|| String::new()); + let mut highlight_idx = use_signal(|| 0usize); + + let magics_state = use_api(move || { + let client = ApiClient::new(); + async move { client.get_magics().await } + }); + + let query = panel_query.read().clone(); + let all_items = match magics_state.data.read().as_ref() { + Some(Ok(groups)) => flatten_magics(groups), + _ => vec![], + }; + let filtered = filter_magics(&all_items, &query); + let items_to_show: Vec<(String, String, String)> = filtered + .into_iter() + .take(50) + .map(|(g, m)| (g, m.command, m.help)) + .collect(); + + let item_count = items_to_show.len(); + if item_count > 0 { + let idx = *highlight_idx.read(); + if idx >= item_count { + *highlight_idx.write() = item_count - 1; + } + } else { + *highlight_idx.write() = 0; + } + + let current_highlight = *highlight_idx.read(); + + let on_select = EventHandler::new(move |selected: String| { + fill_input_and_close(selected); + }); + + rsx! { + div { + class: "fixed inset-0 z-[9998] flex items-start justify-center pt-[15vh] bg-black/20", + onclick: move |_| *COMMAND_PANEL_OPEN.write() = false, + div { + class: "w-full max-w-xl mx-4 bg-white rounded-lg shadow-2xl border border-gray-200 overflow-hidden", + onclick: move |e| { e.stop_propagation(); }, + input { + 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", + value: "{query}", + oninput: move |e| { + *panel_query.write() = e.value(); + *highlight_idx.write() = 0; + }, + onkeydown: move |e: dioxus::html::events::KeyboardEvent| { + use dioxus::html::input_data::keyboard_types::Key; + if e.key() == Key::Escape { + *COMMAND_PANEL_OPEN.write() = false; + } else if e.key() == Key::Enter { + 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); + } + } else if e.key() == Key::ArrowDown { + e.prevent_default(); + if !items_to_show.is_empty() { + let idx = current_highlight.min(items_to_show.len() - 1); + *highlight_idx.write() = (idx + 1).min(items_to_show.len() - 1); + } + } else if e.key() == Key::ArrowUp { + e.prevent_default(); + if current_highlight > 0 { + *highlight_idx.write() = current_highlight - 1; + } + } + }, + } + div { + class: "max-h-96 overflow-y-auto py-1", + if magics_state.is_loading() { + div { class: "px-4 py-6 text-sm text-gray-500", "Loading..." } + } 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" } + } else { + for (i, row) in items_to_show.iter().enumerate() { + CommandPanelItem { + cmd: row.1.clone(), + help: row.2.clone(), + group: row.0.clone(), + is_selected: i == current_highlight, + on_select, + } + } + } + } + } + } + } +} + +/// 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_str("\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", + onclick: move |_| *COMMAND_PANEL_OPEN.write() = true, + "Commands" + } + 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, + } + 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, + } + } + } + } + } + } + } +} + +/// Centered modal showing execution result (like command panel style) +#[component] +pub fn FloatingResultToast(result: Signal>) -> Element { + let opt = 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, + 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(); }, + div { + class: if is_error { "px-4 py-3 bg-red-50 border-b border-red-100 text-red-800 font-medium text-sm" } else { "px-4 py-3 bg-gray-50 border-b border-gray-200 text-gray-800 font-medium text-sm" }, + "{command}" + } + div { + class: "p-4 overflow-y-auto flex-1 text-sm font-mono whitespace-pre-wrap min-h-[200px]", + class: if is_error { "text-red-700" } else { "text-gray-800" }, + if output.is_empty() { + "(no output)" + } else { + "{output}" + } + } + div { + 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, + "Close" + } + } + } + } + } + } else { + rsx! { div {} } + } +} diff --git a/web/src/components/layout.rs b/web/src/components/layout.rs index c2e0bd63..73ec301f 100644 --- a/web/src/components/layout.rs +++ b/web/src/components/layout.rs @@ -1,10 +1,13 @@ //! 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::components::global_command_panel::{CommandBar, FloatingResultToast, GlobalCommandPanel}; use crate::components::icon::Icon; use crate::components::sidebar::Sidebar; +use crate::state::commands::{FloatingResult, COMMAND_PANEL_OPEN}; 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. @@ -14,8 +17,16 @@ const SHOW_SIDEBAR_BUTTON_CLASS: &str = "fixed top-4 left-4 z-50 w-10 h-10 bg-wh pub fn AppLayout(children: Element) -> Element { let _sidebar_width = SIDEBAR_WIDTH.read(); let sidebar_hidden = SIDEBAR_HIDDEN.read(); + let mut floating_result = use_signal(|| Option::::None); rsx! { + if *COMMAND_PANEL_OPEN.read() { + GlobalCommandPanel {} + } + FloatingResultToast { + result: floating_result, + } + div { class: "flex h-screen bg-gray-50 overflow-hidden", if !*sidebar_hidden { @@ -35,12 +46,18 @@ pub fn AppLayout(children: Element) -> Element { } } } - main { - class: "flex-1 overflow-y-auto p-4 sm:p-6 bg-gray-50", - style: if *sidebar_hidden { "width: 100%;" } else { "" }, - div { - class: "max-w-7xl mx-auto w-full", - {children} + div { + class: "flex-1 flex flex-col min-w-0", + CommandBar { + on_execute_done: move |r| *floating_result.write() = Some(r), + } + main { + class: "flex-1 overflow-y-auto p-4 sm:p-6 bg-gray-50", + style: if *sidebar_hidden { "width: 100%;" } else { "" }, + div { + class: "max-w-7xl mx-auto w-full", + {children} + } } } } diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index c1fbcc84..d283bc38 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -13,6 +13,7 @@ //! - **chrome_tracing_iframe** — Chrome Tracing viewer wrapper. pub mod card; +pub mod global_command_panel; pub mod card_view; pub mod callstack_view; pub mod chrome_tracing_iframe; diff --git a/web/src/pages/python.rs b/web/src/pages/python.rs index da56dce4..8333b654 100644 --- a/web/src/pages/python.rs +++ b/web/src/pages/python.rs @@ -1,4 +1,3 @@ - use dioxus::prelude::*; use crate::api::{ApiClient, TraceableItem}; @@ -8,11 +7,8 @@ use crate::components::dataframe_view::DataFrameView; use crate::components::page::{PageContainer, PageTitle}; use crate::hooks::{use_api, use_api_simple}; - #[component] pub fn Python() -> Element { - let mut selected_tab = use_signal(|| "trace".to_string()); - rsx! { PageContainer { PageTitle { @@ -20,25 +16,7 @@ pub fn Python() -> Element { subtitle: Some("Python trace and debug".to_string()), icon: Some(&icondata::SiPython), } - div { - class: "mb-6 border-b border-gray-200", - div { - class: "flex space-x-8", - button { - class: if *selected_tab.read() == "trace" { - format!("py-4 px-1 border-b-2 border-{} font-medium text-sm text-{}", colors::PRIMARY_BORDER, colors::PRIMARY) - } else { - "py-4 px-1 border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300" - }, - onclick: move |_| *selected_tab.write() = "trace".to_string(), - "Trace" - } - } - } - - if *selected_tab.read() == "trace" { - TraceView {} - } + TraceView {} } } } @@ -70,6 +48,7 @@ fn TraceView() -> Element { let mut dialog_function_name = use_signal(|| String::new()); let mut dialog_watch_vars = use_signal(|| String::new()); let mut dialog_print_to_terminal = use_signal(|| false); + let mut dialog_error = use_signal(|| String::new()); rsx! { div { @@ -103,6 +82,7 @@ fn TraceView() -> Element { *dialog_function_name.write() = func_name.clone(); *dialog_watch_vars.write() = vars.join(", "); *dialog_print_to_terminal.write() = false; + *dialog_error.write() = String::new(); *dialog_open.write() = true; *click_signal.write() = (String::new(), Vec::new()); } @@ -137,6 +117,7 @@ fn TraceView() -> Element { dialog_function_name: dialog_function_name.clone(), dialog_watch_vars: dialog_watch_vars.clone(), dialog_print_to_terminal: dialog_print_to_terminal.clone(), + dialog_error: dialog_error.clone(), refresh_key: refresh_key.clone(), } } @@ -532,15 +513,19 @@ fn StartTraceDialog( #[props] dialog_function_name: Signal, #[props] dialog_watch_vars: Signal, #[props] dialog_print_to_terminal: Signal, + #[props] dialog_error: Signal, #[props] refresh_key: Signal, ) -> Element { + let loading = use_signal(|| false); rsx! { div { class: "fixed inset-0 z-50 flex items-center justify-center", div { class: "absolute inset-0 bg-black/50", onclick: move |_| { - *dialog_open.write() = false; + if !*loading.read() { + *dialog_open.write() = false; + } } } div { @@ -615,23 +600,39 @@ fn StartTraceDialog( } } + if !dialog_error.read().is_empty() { + div { + class: "rounded-md bg-red-50 border border-red-200 text-red-700 px-3 py-2 text-sm mb-4", + "{dialog_error.read()}" + } + } + div { class: "flex gap-3 justify-end pt-4", button { - class: "px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500", + class: "px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500 disabled:opacity-50", + disabled: *loading.read(), onclick: move |_| { - *dialog_open.write() = false; + if !*loading.read() { + *dialog_open.write() = false; + } }, "Cancel" } button { - class: format!("px-4 py-2 bg-{} text-white rounded-md hover:bg-{} focus:outline-none focus:ring-2 focus:ring-{} shadow-sm transition-colors", colors::PRIMARY, colors::PRIMARY_HOVER, colors::PRIMARY), + class: format!("px-4 py-2 bg-{} text-white rounded-md hover:bg-{} focus:outline-none focus:ring-2 focus:ring-{} shadow-sm transition-colors disabled:opacity-50", colors::PRIMARY, colors::PRIMARY_HOVER, colors::PRIMARY), + disabled: *loading.read(), onclick: move |_| { let func = dialog_function_name.read().clone(); let watch = dialog_watch_vars.read().clone(); let print_to_terminal = *dialog_print_to_terminal.read(); let mut refresh = refresh_key; let mut dialog_op = dialog_open; + let mut err_msg = dialog_error; + let mut loading_flag = loading; + + *loading_flag.write() = true; + *err_msg.write() = String::new(); spawn(async move { let client = ApiClient::new(); @@ -643,16 +644,24 @@ fn StartTraceDialog( match client.start_trace(&func, Some(watch_list), print_to_terminal).await { Ok(resp) => { - if resp.success { - *refresh.write() += 1; - *dialog_op.write() = false; - } - } - Err(_) => {} + if resp.success { + *refresh.write() += 1; + *dialog_op.write() = false; + } else { + let msg = resp.error + .or(resp.message) + .unwrap_or_else(|| "Start trace failed".to_string()); + *err_msg.write() = msg; } + } + Err(e) => { + *err_msg.write() = e.display_message(); + } + } + *loading_flag.write() = false; }); }, - "Start Trace" + if *loading.read() { "Starting…" } else { "Start Trace" } } } } diff --git a/web/src/state/commands.rs b/web/src/state/commands.rs new file mode 100644 index 00000000..9305bc7a --- /dev/null +++ b/web/src/state/commands.rs @@ -0,0 +1,29 @@ +//! Global state for Command Panel and REPL execution. + +use dioxus::prelude::*; + +/// Output of one eval +#[derive(Clone)] +pub struct EvalState { + pub output: String, + pub is_error: bool, +} + +/// One completed cell: input command + output +#[derive(Clone)] +pub struct Cell { + pub input: String, + pub output: EvalState, +} + +/// Floating result shown after executing a command +#[derive(Clone)] +pub struct FloatingResult { + pub command: String, + pub output: String, + pub is_error: bool, +} + +pub static COMMAND_PANEL_OPEN: GlobalSignal = Signal::global(|| false); +pub static COMMAND_INPUT: GlobalSignal = Signal::global(|| String::new()); +pub static EVAL_HISTORY: GlobalSignal> = Signal::global(Vec::new); diff --git a/web/src/state/mod.rs b/web/src/state/mod.rs index 8d383136..2600a293 100644 --- a/web/src/state/mod.rs +++ b/web/src/state/mod.rs @@ -1,2 +1,3 @@ +pub mod commands; pub mod profiling; pub mod sidebar; From 6eadfd4a17a2112a8c772fd746c0afb3c1799364 Mon Sep 17 00:00:00 2001 From: Reiase Date: Mon, 23 Feb 2026 18:00:40 +0800 Subject: [PATCH 4/7] Update cargo-nextest installation to use --locked flag for consistency in build environment --- .github/actions/setup-build-env/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-build-env/action.yml b/.github/actions/setup-build-env/action.yml index 9030f2e5..0b890dfc 100644 --- a/.github/actions/setup-build-env/action.yml +++ b/.github/actions/setup-build-env/action.yml @@ -64,7 +64,7 @@ runs: run: | test -e ~/.cargo/bin/cargo-zigbuild || cargo install cargo-zigbuild test -e ~/.cargo/bin/rnr || cargo install rnr - test -e ~/.cargo/bin/cargo-nextest || cargo install cargo-nextest + test -e ~/.cargo/bin/cargo-nextest || cargo install --locked cargo-nextest test -e ~/.cargo/bin/cargo-binstall || cargo install cargo-binstall test -e ~/.cargo/bin/dx || cargo binstall dioxus-cli@0.7.0 -y test -e ~/.cargo/bin/trunk || cargo install trunk --locked From 2aa44c98f4fd2f591f2eb04b1303b966627e1301 Mon Sep 17 00:00:00 2001 From: Reiase Date: Mon, 23 Feb 2026 18:18:05 +0800 Subject: [PATCH 5/7] reformat code --- docs/src/design/index.md | 1 + docs/src/design/index.zh.md | 1 + probing/extensions/python/src/extensions/python.rs | 9 +++++---- probing/extensions/python/src/features/torch.rs | 3 ++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/src/design/index.md b/docs/src/design/index.md index 645e6702..409aba92 100644 --- a/docs/src/design/index.md +++ b/docs/src/design/index.md @@ -45,4 +45,5 @@ Probing's core mission is simple: **make distributed systems feel Pythonic again | [Profiling](profiling.md) | Performance data collection | | [Debugging](debugging.md) | Debugging capabilities | | [Distributed](distributed.md) | Multi-node support | +| [Cluster with Pulsing](cluster-pulsing.md) | Using Pulsing for membership and failure detection | | [Extensibility](extensibility.md) | Custom tables and metrics | diff --git a/docs/src/design/index.zh.md b/docs/src/design/index.zh.md index 10c4fcf0..6ab7c14a 100644 --- a/docs/src/design/index.zh.md +++ b/docs/src/design/index.zh.md @@ -45,4 +45,5 @@ Probing 的核心使命很简单:**让分布式系统重新变得 Pythonic** | [性能分析](profiling.md) | 性能数据收集 | | [调试](debugging.md) | 调试能力 | | [分布式](distributed.md) | 多节点支持 | +| [基于 Pulsing 的集群](cluster-pulsing.zh.md) | 使用 Pulsing 做成员发现与故障检测 | | [扩展机制](extensibility.md) | 自定义表和指标 | diff --git a/probing/extensions/python/src/extensions/python.rs b/probing/extensions/python/src/extensions/python.rs index 88075c36..26426c64 100644 --- a/probing/extensions/python/src/extensions/python.rs +++ b/probing/extensions/python/src/extensions/python.rs @@ -175,15 +175,16 @@ impl PythonExt { log::debug!("Python eval code: {code}"); let mut repl = PythonRepl::default(); - let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - repl.process(code.as_str()) - })); + let out = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| repl.process(code.as_str()))); match out { Ok(Some(s)) => Ok(s.into_bytes()), Ok(None) => Ok(Vec::new()), Err(_) => { log::error!("Python REPL process panicked; returning error response"); - Ok(serde_json::json!({"error": "REPL execution panicked"}).to_string().into_bytes()) + Ok(serde_json::json!({"error": "REPL execution panicked"}) + .to_string() + .into_bytes()) } } } diff --git a/probing/extensions/python/src/features/torch.rs b/probing/extensions/python/src/features/torch.rs index 068a258b..12203c6b 100644 --- a/probing/extensions/python/src/features/torch.rs +++ b/probing/extensions/python/src/features/torch.rs @@ -63,7 +63,8 @@ pub fn query_profiling() -> Result> { .enable_all() .build() .unwrap(); - Ok(rt.block_on(async { engine.async_query(TORCH_QUERY).await })? + Ok(rt + .block_on(async { engine.async_query(TORCH_QUERY).await })? .unwrap_or_default()) }) .join() From 3ffad54865ba57483f424e4ff8a1a45af11982c9 Mon Sep 17 00:00:00 2001 From: Reiase Date: Mon, 23 Feb 2026 18:32:03 +0800 Subject: [PATCH 6/7] Update doctest output in engine.py to use ellipsis for DataFrame type representation --- python/probing/core/engine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/probing/core/engine.py b/python/probing/core/engine.py index 9837b87d..27342637 100644 --- a/python/probing/core/engine.py +++ b/python/probing/core/engine.py @@ -13,8 +13,8 @@ >>> import probing >>> # Execute a simple SQL query >>> df = probing.query("SHOW TABLES") - >>> type(df) - + >>> type(df) # doctest: +ELLIPSIS + >>> # Load a custom extension >>> mod = probing.load_extension("probing.ext.example") From 0dc5f936d9cdd811b973dae687a1ed0e717508bf Mon Sep 17 00:00:00 2001 From: Reiase Date: Mon, 23 Feb 2026 18:38:02 +0800 Subject: [PATCH 7/7] Update documentation dependencies in requirements.txt - Changed mkdocs version to 1.6.1 for compatibility. - Updated mkdocs-material to version 9.7.2 for new features and improvements. --- docs/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 6d4373e2..012126a6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,8 +1,8 @@ # Documentation dependencies for Probing # Auto-generated from pyproject.toml -mkdocs>=1.5.0 -mkdocs-material>=9.4.0 +mkdocs==1.6.1 +mkdocs-material>=9.7.2 mkdocs-material-extensions>=1.3.0 mkdocs-static-i18n>=1.0.0 mkdocstrings>=0.24.0