diff --git a/crates/analyzer/src/error.rs b/crates/analyzer/src/error.rs index f530ce0a3..67f7fc6ba 100644 --- a/crates/analyzer/src/error.rs +++ b/crates/analyzer/src/error.rs @@ -28,4 +28,6 @@ pub enum AnalyzerError { FsmExitTransitionConversion, #[error("invalid argument: {0}")] InvalidArgument(String), + #[error("this analyzer does not support the requested capability")] + Unsupported, } diff --git a/crates/analyzer/src/timeline/binned/categorical.rs b/crates/analyzer/src/timeline/binned/categorical.rs new file mode 100644 index 000000000..ab9a19a1e --- /dev/null +++ b/crates/analyzer/src/timeline/binned/categorical.rs @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Binned timelines of weighted values keyed by categories. +//! +//! A categorical timeline describes, per opaque series (e.g. an operator in a +//! query engine), how some weighted quantity (a "measure", e.g. an entity +//! count) breaks down over the states of a finite state machine and an +//! application-defined dimension (e.g. which resource holds the entity's +//! data), for each time bin of a window. Bin values are absolute, +//! time-weighted quantities — not normalized shares. +//! +//! This module is application-agnostic: series, measures, states, and +//! dimension keys are opaque to the aggregation. Downstream analyzers decide +//! what they mean and are expected to keep dimension keys a small enumerable +//! set. + +use std::hash::Hash; + +use rustc_hash::FxHashMap as HashMap; + +use quent_time::{SpanNanoSec, bin::BinnedSpan}; + +use crate::{ + AnalyzerResult, + timeline::binned::{BinnedTimelineAggregator, KeyedAggregator}, +}; + +/// Identity of one aggregation cell of a categorical timeline. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CategoricalKey { + /// Opaque series the sample belongs to (e.g. an operator id downstream). + pub series: S, + /// The measure this weight contributes to (e.g. an entity count). + pub measure: M, + /// The FSM state name during the span. + pub state: St, + /// Application-defined dimension key (opaque to the aggregation). + pub dimension: D, +} + +/// A binned timeline of weighted (state, dimension) values for multiple +/// series and measures. +#[derive(Clone, Debug)] +pub struct CategoricalTimeline { + pub config: BinnedSpan, + pub data: HashMap, Vec>, +} + +/// Builds a [`CategoricalTimeline`] from weighted samples. +/// +/// Aggregation is span-weighted: each sample contributes +/// `weight * overlap_fraction` to every bin its span intersects, so bin values +/// are time-weighted averages over the bin, not instantaneous snapshots. +pub struct CategoricalTimelineBuilder { + aggregator: KeyedAggregator>, +} + +impl CategoricalTimelineBuilder +where + S: Eq + Hash, + M: Eq + Hash, + St: Eq + Hash, + D: Eq + Hash, +{ + pub fn new(config: BinnedSpan) -> Self { + Self { + aggregator: KeyedAggregator::new(config), + } + } + + /// Return the configuration of the binned timeline. + pub fn config(&self) -> BinnedSpan { + self.aggregator.config() + } + + /// Attempt to push one weighted sample spanning `span` into the timeline. + pub fn try_push( + &mut self, + key: CategoricalKey, + span: SpanNanoSec, + weight: f64, + ) -> AnalyzerResult<()> { + self.aggregator.try_push(span, (key, weight)) + } + + pub fn build(self) -> CategoricalTimeline { + CategoricalTimeline { + config: self.aggregator.config(), + data: self.aggregator.finish(), + } + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZero; + + use super::*; + + fn test_config() -> BinnedSpan { + BinnedSpan::try_new( + SpanNanoSec::try_new(0, 1000).unwrap(), + NonZero::try_from(10).unwrap(), + ) + .unwrap() + } + + fn key<'a>( + series: u32, + measure: &'a str, + state: &'a str, + dimension: &'a str, + ) -> CategoricalKey { + CategoricalKey { + series, + measure, + state, + dimension, + } + } + + /// Samples differing in any key component land in distinct cells; the + /// binning math itself is covered by the aggregator's own tests. + #[test] + fn distinct_series_measures_states_dimensions() -> AnalyzerResult<()> { + let mut builder = CategoricalTimelineBuilder::new(test_config()); + let span = SpanNanoSec::try_new(0, 1000).unwrap(); + + builder.try_push(key(1, "count", "a", "x"), span, 1.0)?; + builder.try_push(key(2, "count", "a", "x"), span, 1.0)?; + builder.try_push(key(1, "bytes", "a", "x"), span, 100.0)?; + builder.try_push(key(1, "count", "b", "x"), span, 1.0)?; + builder.try_push(key(1, "count", "a", "y"), span, 1.0)?; + + let timeline = builder.build(); + assert_eq!(timeline.data.len(), 5); + assert_eq!( + timeline.data.get(&key(1, "bytes", "a", "x")).unwrap()[..], + [100.0; 10] + ); + assert_eq!( + timeline.data.get(&key(2, "count", "a", "x")).unwrap()[..], + [1.0; 10] + ); + Ok(()) + } + + /// Non-string key components only need `Eq + Hash` — no stringification. + #[test] + fn non_string_key_components() -> AnalyzerResult<()> { + #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] + enum Measure { + Count, + } + + let mut builder: CategoricalTimelineBuilder = + CategoricalTimelineBuilder::new(test_config()); + let cell = CategoricalKey { + series: 7u32, + measure: Measure::Count, + state: 3u8, + dimension: 9u16, + }; + builder.try_push(cell.clone(), SpanNanoSec::try_new(0, 1000).unwrap(), 2.0)?; + + let timeline = builder.build(); + assert_eq!(timeline.data.get(&cell).unwrap()[..], [2.0; 10]); + Ok(()) + } +} diff --git a/crates/analyzer/src/timeline/binned/mod.rs b/crates/analyzer/src/timeline/binned/mod.rs index 56110fe94..f6380ec8e 100644 --- a/crates/analyzer/src/timeline/binned/mod.rs +++ b/crates/analyzer/src/timeline/binned/mod.rs @@ -9,6 +9,7 @@ use quent_time::{SpanNanoSec, bin::BinnedSpan}; use crate::AnalyzerResult; +pub mod categorical; pub mod resource; /// A trait for types that can aggregate items into a sequence of time bins. @@ -146,4 +147,50 @@ mod tests { Ok(()) } + + fn ten_bin_config() -> BinnedSpan { + BinnedSpan::try_new( + SpanNanoSec::try_new(0, 1000).unwrap(), + NonZero::new(10).unwrap(), + ) + .unwrap() + } + + /// Overlapping spans accumulate span-weighted fractions per bin. + #[test] + fn keyed_aggregator_span_weighting_across_bin_boundaries() -> AnalyzerResult<()> { + let mut aggregator: KeyedAggregator<&str> = KeyedAggregator::new(ten_bin_config()); + + aggregator.try_push(SpanNanoSec::try_new(0, 300).unwrap(), ("k", 1.0))?; + aggregator.try_push(SpanNanoSec::try_new(250, 450).unwrap(), ("k", 1.0))?; + + let bins = aggregator.finish(); + assert_eq!( + bins.get("k").unwrap()[..], + [1.0, 1.0, 1.5, 1.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0] + ); + Ok(()) + } + + /// Zero-duration spans create the key but contribute nothing. + #[test] + fn keyed_aggregator_zero_duration_span_is_noop() -> AnalyzerResult<()> { + let mut aggregator: KeyedAggregator<&str> = KeyedAggregator::new(ten_bin_config()); + aggregator.try_push(SpanNanoSec::try_new(500, 500).unwrap(), ("k", 1.0))?; + + let bins = aggregator.finish(); + assert_eq!(bins.get("k").unwrap()[..], [0.0; 10]); + Ok(()) + } + + /// Spans entirely outside the window contribute nothing. + #[test] + fn keyed_aggregator_out_of_window_span_contributes_nothing() -> AnalyzerResult<()> { + let mut aggregator: KeyedAggregator<&str> = KeyedAggregator::new(ten_bin_config()); + aggregator.try_push(SpanNanoSec::try_new(2000, 3000).unwrap(), ("k", 1.0))?; + + let bins = aggregator.finish(); + assert_eq!(bins.get("k").unwrap()[..], [0.0; 10]); + Ok(()) + } } diff --git a/crates/ui/src/timeline/categorical.rs b/crates/ui/src/timeline/categorical.rs new file mode 100644 index 000000000..7cfabf00d --- /dev/null +++ b/crates/ui/src/timeline/categorical.rs @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Requests and responses for categorical timelines: binned timelines of +//! weighted values broken down by (FSM state, application-defined dimension), +//! for one or more application-declared measures. Bin values are absolute +//! time-weighted quantities, not normalized shares. +//! +//! All semantics are declared by the downstream analyzer: which FSM the states +//! belong to, what the dimension keys mean, and which measures exist. The UI +//! renders the declared names verbatim. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::{quantity::CapacityKind, timeline::request::TimelineConfig}; + +/// Request for a categorical timeline. +#[derive(TS, Debug, Clone, Serialize, Deserialize)] +pub struct CategoricalTimelineRequest { + /// Names of the measures to compute. Empty means all declared measures. + pub measures: Vec, + /// The configuration of the window and number of bins. + pub config: TimelineConfig, + /// Global application-specific parameters, e.g. filters. + pub app_params: GlobalParams, +} + +/// A measure declared by the downstream analyzer for a categorical timeline, +/// e.g. an entity count or a number of bytes. +#[derive(TS, Debug, Clone, Serialize)] +pub struct MeasureDecl { + /// Unique name; key into [`CategoricalSeries::values`]. + pub name: String, + /// Human-friendly display name. + pub display_name: String, + /// Key into the application's quantity specs map for unit formatting. + pub quantity: String, + /// Display semantics of a bin value (Occupancy = time-weighted level). + pub kind: CapacityKind, +} + +/// One key of the application-defined dimension of a categorical timeline. +#[derive(TS, Debug, Clone, Serialize)] +pub struct DimensionKeyDecl { + /// The key used in [`CategoricalSeries::values`]. + pub key: String, + /// Human-friendly display name. + pub display_name: String, +} + +/// Presentation metadata for a categorical timeline, declared by the +/// downstream analyzer. +/// +/// Dimension keys are expected to be a small enumerable set; unbounded key +/// cardinality is a downstream misuse. +#[derive(TS, Debug, Clone, Serialize)] +pub struct CategoricalDecl { + /// The FSM type whose states are broken down. References an entry in the + /// application's FSM type declarations (e.g. `QueryBundle` fsm_types) for + /// the state graph, names, and ordering. + pub entity_type_name: String, + /// Display name of the dimension, e.g. an application may use a dimension + /// describing where an entity's data resides. + pub dimension_name: String, + /// The dimension keys in stable stacking/legend order. + pub dimension_keys: Vec, + /// The measures present in this response. + pub measures: Vec, + /// The measure the UI should select by default; must name an entry in + /// `measures`. `None` means the first declared measure. + pub default_measure: Option, +} + +/// Binned values of one categorical timeline series: +/// measure name -> state name -> dimension key -> one value per time bin. +/// +/// Absent inner entries mean all-zero bins. +#[derive(TS, Debug, Clone, Default, Serialize)] +pub struct CategoricalSeries { + pub values: HashMap>>>, +} diff --git a/crates/ui/src/timeline/mod.rs b/crates/ui/src/timeline/mod.rs index 9d67199fa..5f7a50cc6 100644 --- a/crates/ui/src/timeline/mod.rs +++ b/crates/ui/src/timeline/mod.rs @@ -2,5 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 //! Requests and responses for timelines. +pub mod categorical; pub mod request; pub mod response; diff --git a/domains/query_engine/analyzer/src/ui.rs b/domains/query_engine/analyzer/src/ui.rs index 0e28316bd..d2a666e37 100644 --- a/domains/query_engine/analyzer/src/ui.rs +++ b/domains/query_engine/analyzer/src/ui.rs @@ -4,13 +4,14 @@ use std::collections::HashMap; use std::path::Path; -use quent_analyzer::AnalyzerResult; +use quent_analyzer::{AnalyzerError, AnalyzerResult}; use quent_events::Event; use quent_model::io::ImporterResult; use quent_query_engine_ui as ui; use quent_ui::{ entities::{request::EntityListRequest, response::EntityListResponse}, timeline::{ + categorical::CategoricalTimelineRequest, request::{BulkChunkedTimelineRequest, BulkTimelineRequest, SingleTimelineRequest}, response::{ BulkChunkedTimelinesResponse, BulkTimelinesResponse, BulkTimelinesResponseEntry, @@ -116,6 +117,21 @@ pub trait UiAnalyzer { Ok(BulkChunkedTimelinesResponse { entries }) } + + /// Return, for every operator of a query, a binned categorical timeline + /// over (entity state, analyzer-defined dimension), for one or more + /// analyzer-declared measures. Powers the UI's data-flow-over-time view of + /// the query plan. + /// + /// The default implementation returns [`AnalyzerError::Unsupported`] + /// (served as HTTP 501), so existing analyzers keep compiling and the UI + /// hides the view. + fn data_flow_timeline( + &self, + _request: CategoricalTimelineRequest, + ) -> AnalyzerResult { + Err(AnalyzerError::Unsupported) + } } /// Boxed owned stream of an analyzer's [`UiAnalyzer::Event`] from diff --git a/domains/query_engine/server/src/error.rs b/domains/query_engine/server/src/error.rs index 1ed3230a2..2fd37dc01 100644 --- a/domains/query_engine/server/src/error.rs +++ b/domains/query_engine/server/src/error.rs @@ -27,6 +27,12 @@ pub type ServerResult = std::result::Result; impl From for StatusCode { fn from(value: ServerError) -> Self { match value { + // A capability the analyzer opted out of is not a server fault: + // clients probe for optional endpoints (e.g. the data-flow view) + // and hide the feature on 501. + ServerError::Analyzer(quent_analyzer::AnalyzerError::Unsupported) => { + StatusCode::NOT_IMPLEMENTED + } ServerError::Importer(_) | ServerError::Analyzer(_) | ServerError::Io(_) diff --git a/domains/query_engine/server/src/ui.rs b/domains/query_engine/server/src/ui.rs index 119155537..437294d7f 100644 --- a/domains/query_engine/server/src/ui.rs +++ b/domains/query_engine/server/src/ui.rs @@ -12,6 +12,7 @@ use quent_query_engine_analyzer::{QueryEngineModel, query_group::QueryGroup, ui: use quent_query_engine_ui as ui; use quent_ui::entities::{request::EntityListRequest, response::EntityListResponse}; use quent_ui::timeline::{ + categorical::CategoricalTimelineRequest, request::{BulkTimelineRequest, SingleTimelineRequest}, response::{BulkTimelinesResponse, SingleTimelineResponse}, }; @@ -262,6 +263,38 @@ where )) } +/// Fetch the per-operator data-flow distribution timeline for a query. +/// +/// Not cached in v1; the response shape is `combine_chunks`-compatible +/// (`BinnedSpanSec` config with `Vec` leaves), so chunked caching à la +/// `timeline_cache` can be added later without a protocol change. +#[cfg_attr(feature = "swagger", utoipa::path( + post, + path = "/api/engines/{engine_id}/timeline/data-flow", + tag = "timelines", + params( + ("engine_id" = Uuid, Path, description = "The engine ID") + ), + request_body = Object, + responses( + (status = 200, description = "Per-operator categorical data-flow timeline; 501 when the analyzer does not support it", body = Object) + ) +))] +#[tracing::instrument(skip_all, err)] +async fn data_flow_timeline( + State(state): State>, + Path(engine_id): Path, + Json(request): Json>, +) -> ServerResult> +where + A: UiAnalyzer + Send + Sync + 'static, +{ + let analyzer = state.analyzers.get(engine_id).await?; + Ok(Json( + tokio::task::spawn_blocking(move || analyzer.data_flow_timeline(request)).await??, + )) +} + /// List the entities of a resource or resource group, ranked and paged. #[cfg_attr(feature = "swagger", utoipa::path( post, @@ -299,6 +332,7 @@ where query, single_timeline, bulk_timelines, + data_flow_timeline, entities, ), tags( @@ -325,6 +359,7 @@ where .route("/{engine_id}/query/{query_id}", get(query)) .route("/{engine_id}/timeline/single", post(single_timeline)) .route("/{engine_id}/timeline/bulk", post(bulk_timelines)) + .route("/{engine_id}/timeline/data-flow", post(data_flow_timeline)) .route("/{engine_id}/entities", post(entities)) .with_state(state) } diff --git a/domains/query_engine/tests/fixed/tests/data_flow.rs b/domains/query_engine/tests/fixed/tests/data_flow.rs new file mode 100644 index 000000000..03f3c3910 --- /dev/null +++ b/domains/query_engine/tests/fixed/tests/data_flow.rs @@ -0,0 +1,255 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Functional test of `UiAnalyzer::data_flow_timeline` over the fixed scenario. +//! +//! Ground truth (times relative to the query epoch at 1s absolute): each task +//! queues + allocates at its slot start, computes from +250ms, and exits at +//! the slot end. `PHYS_SCAN_FILTER_W0` runs TASK_0 and TASK_1 in its 1–2s +//! slot; `PHYS_PARTIAL_AGG_W1` runs TASK_6 and TASK_7 in its 2–3s slot with a +//! `sending` state from +500ms. Computing holds 256 bytes of the worker's +//! "memory" resource; allocating and sending hold no memory. + +use quent_io::{EventCallback, ExporterOptions}; +use quent_query_engine_analyzer::ui::UiAnalyzer; +use quent_query_engine_fixed as fixed; +use quent_query_engine_ui::DataFlowTimelineBinned; +use quent_query_engine_ui::QueryFilter; +use quent_simulator_analyzer::SimulatorUiAnalyzer; +use quent_simulator_instrumentation::{SimulatorContext, test_utils::events_from_recorded}; +use quent_ui::timeline::{categorical::CategoricalTimelineRequest, request::TimelineConfig}; +use std::sync::{Arc, Mutex}; + +/// Emit the fixed scenario into memory via a callback exporter and build an +/// analyzer from the captured events. +fn fixed_analyzer() -> SimulatorUiAnalyzer { + let recorded = Arc::new(Mutex::new(Vec::new())); + { + let captured = Arc::clone(&recorded); + let ctx = SimulatorContext::try_new(Some(ExporterOptions::Callback(EventCallback::new( + move |event| captured.lock().unwrap().push(event), + )))) + .unwrap(); + fixed::emit(&ctx); + // ctx dropped here, flushing all events to the callback. + } + + let events = events_from_recorded(std::mem::take(&mut *recorded.lock().unwrap())); + SimulatorUiAnalyzer::try_new(fixed::ENGINE, events.into_iter()).unwrap() +} + +/// A whole-query request: 7 one-second bins over the 0–7s window. +fn request(measures: &[&str]) -> CategoricalTimelineRequest { + CategoricalTimelineRequest { + measures: measures.iter().map(|m| m.to_string()).collect(), + config: TimelineConfig { + num_bins: 7, + start: 0.0, + end: 7.0, + }, + app_params: QueryFilter { + query_id: fixed::QUERY, + }, + } +} + +fn bins<'a>( + binned: &'a DataFlowTimelineBinned, + operator: uuid::Uuid, + measure: &str, + state: &str, + dimension: &str, +) -> Option<&'a Vec> { + binned + .operators + .get(&operator)? + .values + .get(measure)? + .get(state)? + .get(dimension) +} + +#[test] +fn declares_states_dimensions_and_measures() { + let analyzer = fixed_analyzer(); + let result = analyzer.data_flow_timeline(request(&[])).unwrap(); + + assert_eq!(result.decl.entity_type_name, "task"); + assert_eq!(result.decl.dimension_name, "Data location"); + // Both workers' memory resources share the instance name "memory"; the + // no-memory key comes last. + assert_eq!( + result + .decl + .dimension_keys + .iter() + .map(|k| k.key.as_str()) + .collect::>(), + ["memory", "none"] + ); + assert_eq!( + result + .decl + .measures + .iter() + .map(|m| m.name.as_str()) + .collect::>(), + ["tasks", "bytes"] + ); + assert_eq!(result.config.num_bins, 7); +} + +#[test] +fn distributes_scan_filter_tasks_over_states_and_locations() { + let analyzer = fixed_analyzer(); + let result = analyzer.data_flow_timeline(request(&[])).unwrap(); + + // Two tasks allocating (no memory) for 0.25s each within bin 1. + assert_eq!( + bins( + &result, + fixed::PHYS_SCAN_FILTER_W0, + "tasks", + "allocating", + "none" + ) + .unwrap()[..], + [0.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0] + ); + // Two tasks computing in memory for 0.75s each within bin 1. + assert_eq!( + bins( + &result, + fixed::PHYS_SCAN_FILTER_W0, + "tasks", + "computing", + "memory" + ) + .unwrap()[..], + [0.0, 1.5, 0.0, 0.0, 0.0, 0.0, 0.0] + ); + // 2 tasks x 256 bytes x 0.75 bin fraction. + assert_eq!( + bins( + &result, + fixed::PHYS_SCAN_FILTER_W0, + "bytes", + "computing", + "memory" + ) + .unwrap()[..], + [0.0, 384.0, 0.0, 0.0, 0.0, 0.0, 0.0] + ); + // Queueing is zero-duration in this scenario: filtered out as all-zero. + assert!( + bins( + &result, + fixed::PHYS_SCAN_FILTER_W0, + "tasks", + "queueing", + "none" + ) + .is_none() + ); +} + +#[test] +fn sending_state_counts_without_memory_location() { + let analyzer = fixed_analyzer(); + let result = analyzer.data_flow_timeline(request(&[])).unwrap(); + + // TASK_6/TASK_7: allocating 2.0-2.25, computing 2.25-2.5, sending 2.5-3.0. + assert_eq!( + bins( + &result, + fixed::PHYS_PARTIAL_AGG_W1, + "tasks", + "allocating", + "none" + ) + .unwrap()[..], + [0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0] + ); + assert_eq!( + bins( + &result, + fixed::PHYS_PARTIAL_AGG_W1, + "tasks", + "computing", + "memory" + ) + .unwrap()[..], + [0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0] + ); + // The channel usage during sending is not a memory resource: location "none". + assert_eq!( + bins( + &result, + fixed::PHYS_PARTIAL_AGG_W1, + "tasks", + "sending", + "none" + ) + .unwrap()[..], + [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0] + ); + assert_eq!( + bins( + &result, + fixed::PHYS_PARTIAL_AGG_W1, + "bytes", + "computing", + "memory" + ) + .unwrap()[..], + [0.0, 0.0, 128.0, 0.0, 0.0, 0.0, 0.0] + ); +} + +#[test] +fn measures_filter_restricts_response_and_decl() { + let analyzer = fixed_analyzer(); + let result = analyzer.data_flow_timeline(request(&["tasks"])).unwrap(); + + assert_eq!( + result + .decl + .measures + .iter() + .map(|m| m.name.as_str()) + .collect::>(), + ["tasks"] + ); + assert!( + bins( + &result, + fixed::PHYS_SCAN_FILTER_W0, + "tasks", + "computing", + "memory" + ) + .is_some() + ); + assert!( + bins( + &result, + fixed::PHYS_SCAN_FILTER_W0, + "bytes", + "computing", + "memory" + ) + .is_none() + ); +} + +#[test] +fn unknown_measures_are_an_error() { + let analyzer = fixed_analyzer(); + assert!(analyzer.data_flow_timeline(request(&["bogus"])).is_err()); + // A typo alongside valid measures must not be silently ignored. + assert!( + analyzer + .data_flow_timeline(request(&["tasks", "bogus"])) + .is_err() + ); +} diff --git a/domains/query_engine/ui/src/data_flow.rs b/domains/query_engine/ui/src/data_flow.rs new file mode 100644 index 000000000..bc505f409 --- /dev/null +++ b/domains/query_engine/ui/src/data_flow.rs @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Types for the per-operator data-flow categorical timeline. + +use std::collections::HashMap; + +use quent_time::bin::BinnedSpanSec; +use quent_ui::timeline::categorical::{CategoricalDecl, CategoricalSeries}; +use serde::Serialize; +use ts_rs::TS; +use uuid::Uuid; + +/// A binned data-flow categorical timeline covering every operator of a +/// query. Analyzers without data-flow telemetry return +/// `AnalyzerError::Unsupported` instead (HTTP 501), which the UI treats as +/// "hide the view". +#[derive(TS, Debug, Clone, Serialize)] +pub struct DataFlowTimelineBinned { + /// The configuration of the binned timeline. + /// + /// This may slightly differ from the requested configuration to ensure + /// bounds are not exceeded and bin sizes are equal. + pub config: BinnedSpanSec, + /// Presentation metadata declared by the analyzer. + pub decl: CategoricalDecl, + /// Categorical series keyed by operator id. + pub operators: HashMap, +} diff --git a/domains/query_engine/ui/src/lib.rs b/domains/query_engine/ui/src/lib.rs index 5f0d8204c..9143b4b91 100644 --- a/domains/query_engine/ui/src/lib.rs +++ b/domains/query_engine/ui/src/lib.rs @@ -3,6 +3,9 @@ //! Types shared with the UI. +mod data_flow; +pub use data_flow::DataFlowTimelineBinned; + use quent_analyzer::fsm::FsmTypeDecl; use quent_attributes::{Attribute, Value}; use quent_query_engine_model as qe; diff --git a/examples/simulator/analyzer/src/lib.rs b/examples/simulator/analyzer/src/lib.rs index f9e142607..8fd872784 100644 --- a/examples/simulator/analyzer/src/lib.rs +++ b/examples/simulator/analyzer/src/lib.rs @@ -7,11 +7,17 @@ use quent_query_engine_analyzer::{ entities, ui::{QuentViewer, UiAnalyzer, ViewerEventStream}, }; -use quent_query_engine_ui::{OperatorFilter, QueryBundle, QueryEntities, QueryFilter}; +use quent_query_engine_ui::{ + DataFlowTimelineBinned, OperatorFilter, QueryBundle, QueryEntities, QueryFilter, +}; use quent_ui::{ FiniteStateMachine, ResourceGroupNode, ResourceTree, convert_resource_tree, - quantity::QuantitySpec, + quantity::{CapacityKind, QuantitySpec}, timeline::{ + categorical::{ + CategoricalDecl, CategoricalSeries, CategoricalTimelineRequest, DimensionKeyDecl, + MeasureDecl, + }, request::{ BulkChunkedTimelineRequest, BulkTimelineRequest, EntityFilter, SingleTimelineRequest, TimelineRequest, @@ -30,18 +36,21 @@ use tracing::debug; use quent_analyzer::{ AnalyzerError, AnalyzerResult, Entity, Model, Span, - fsm::{FsmTypeDeclaration, FsmUsages}, + fsm::{FsmTypeDeclaration, FsmUsages, Transition}, resource::{ ResourceTypeDecl, Usage, Using, collection::ResourceCollection, tree::ResourceTreeNode, }, - timeline::binned::resource::{ - ResourceTimeline, ResourceTimelineBuilder, ResourceTimelineByKey, - ResourceTimelineByKeyBuilder, + timeline::binned::{ + categorical::{CategoricalKey, CategoricalTimelineBuilder}, + resource::{ + ResourceTimeline, ResourceTimelineBuilder, ResourceTimelineByKey, + ResourceTimelineByKeyBuilder, + }, }, }; use quent_simulator_instrumentation::{Simulator, SimulatorEvent}; use quent_simulator_ui::EntityRef; -use quent_time::{SpanNanoSec, TimeNanoSec, TimeUnixNanoSec, to_nanosecs, to_secs}; +use quent_time::{SpanNanoSec, TimeNanoSec, TimeUnixNanoSec, Timestamp, to_nanosecs, to_secs}; use uuid::Uuid; use crate::{ @@ -53,6 +62,15 @@ pub mod model; pub mod task; pub mod view; +/// Data-flow measure counting tasks residing in each (state, location) cell. +const MEASURE_TASKS: &str = "tasks"; +/// Data-flow measure summing memory bytes held in each (state, location) cell. +const MEASURE_BYTES: &str = "bytes"; +/// Data-flow dimension key for states that hold no memory resource. +const DIMENSION_NONE: &str = "none"; +/// Type name of stdlib memory resources as recorded by the model. +const MEMORY_TYPE_NAME: &str = "memory"; + pub struct SimulatorUiAnalyzer { pub model: SimulatorModel, } @@ -774,6 +792,189 @@ impl UiAnalyzer for SimulatorUiAnalyzer { Ok(BulkChunkedTimelinesResponse { entries }) } + + fn data_flow_timeline( + &self, + request: CategoricalTimelineRequest, + ) -> AnalyzerResult { + let query_id = request.app_params.query_id; + let epoch = self.query_engine_model().query_epoch(query_id)?; + let config = request.config.try_into_binned_span(epoch)?; + + // Which of the declared measures to compute; empty means all. Any + // unknown name is an error, even alongside valid ones — silently + // ignoring it would hide client typos. + if let Some(unknown) = request + .measures + .iter() + .find(|m| *m != MEASURE_TASKS && *m != MEASURE_BYTES) + { + return Err(AnalyzerError::InvalidArgument(format!( + "unknown measure '{unknown}'; declared measures are '{MEASURE_TASKS}' and '{MEASURE_BYTES}'" + ))); + } + let want = + |name: &str| request.measures.is_empty() || request.measures.iter().any(|m| m == name); + let want_tasks = want(MEASURE_TASKS); + let want_bytes = want(MEASURE_BYTES); + + let query_operators: HashSet = self + .model + .query_view(query_id)? + .operators() + .map(|op| op.id()) + .collect(); + + // The dimension of the distribution is where a task's data resides: + // the instance name of the memory-typed resource its state uses, or + // `DIMENSION_NONE` for states that hold no memory. + let memory_names: HashMap = self + .model + .arbitrary_resources + .resources() + .filter(|r| r.type_name() == MEMORY_TYPE_NAME) + .map(|r| (r.id(), r.instance_name())) + .collect(); + + // The no-memory sentinel must never collide with a real resource + // name; grow it until it is unique among memory instance names. + let mut none_key = DIMENSION_NONE.to_owned(); + while memory_names.values().any(|name| *name == none_key) { + none_key.push('_'); + } + // Dimension keys actually observed for this query's tasks; the decl + // advertises only these (not every memory in the engine model). + let mut present_dimensions: HashSet<&str> = HashSet::default(); + + let mut builder = CategoricalTimelineBuilder::new(config); + for task in self.model.tasks.values() { + let Some(operator_id) = task.operator_id() else { + continue; + }; + if !query_operators.contains(&operator_id) { + continue; + } + // Walk state spans: state `i` spans transition `i` to `i + 1`. Use + // raw transitions rather than `usages_with_state_names` so states + // without usages still count. + for pair in task.transitions().windows(2) { + let (from, to) = (&pair[0], &pair[1]); + let Ok(span) = SpanNanoSec::try_new(from.timestamp(), to.timestamp()) else { + continue; + }; + let state = from.name(); + let memory_usage = from + .usages + .iter() + .find(|u| memory_names.contains_key(&u.resource_id)); + let dimension = + memory_usage.map_or(none_key.as_str(), |u| memory_names[&u.resource_id]); + if want_tasks { + present_dimensions.insert(dimension); + builder.try_push( + CategoricalKey { + series: operator_id, + measure: MEASURE_TASKS, + state, + dimension, + }, + span, + 1.0, + )?; + } + if want_bytes { + let bytes: u64 = memory_usage + .map(|u| { + u.capacities + .iter() + .filter(|c| c.name == "capacity_bytes") + .filter_map(|c| c.value) + .sum() + }) + .unwrap_or(0); + if bytes > 0 { + present_dimensions.insert(dimension); + builder.try_push( + CategoricalKey { + series: operator_id, + measure: MEASURE_BYTES, + state, + dimension, + }, + span, + bytes as f64, + )?; + } + } + } + } + + // Pivot the flat aggregation into per-operator nested series. All-zero + // series (e.g. from zero-duration states) are omitted; the protocol + // treats absent entries as all-zero bins. + let mut operators: StdHashMap = StdHashMap::new(); + for (key, bins) in builder.build().data { + if bins.iter().all(|v| *v == 0.0) { + continue; + } + operators + .entry(key.series) + .or_default() + .values + .entry(key.measure.to_owned()) + .or_default() + .entry(key.state.to_owned()) + .or_default() + .insert(key.dimension.to_owned(), bins); + } + + let has_none = present_dimensions.remove(none_key.as_str()); + let mut memory_instance_names: Vec<&str> = present_dimensions.into_iter().collect(); + memory_instance_names.sort_unstable(); + let mut dimension_keys: Vec = memory_instance_names + .into_iter() + .map(|name| DimensionKeyDecl { + key: name.to_owned(), + display_name: name.to_owned(), + }) + .collect(); + if has_none { + dimension_keys.push(DimensionKeyDecl { + key: none_key.clone(), + display_name: "No data resident".to_owned(), + }); + } + + let mut measures = Vec::new(); + if want_tasks { + measures.push(MeasureDecl { + name: MEASURE_TASKS.to_owned(), + display_name: "Tasks".to_owned(), + quantity: "unit".to_owned(), + kind: CapacityKind::Occupancy, + }); + } + if want_bytes { + measures.push(MeasureDecl { + name: MEASURE_BYTES.to_owned(), + display_name: "Resident bytes".to_owned(), + quantity: "capacity_bytes".to_owned(), + kind: CapacityKind::Occupancy, + }); + } + + Ok(DataFlowTimelineBinned { + config: config.try_to_secs_relative(epoch)?, + decl: CategoricalDecl { + entity_type_name: Task::fsm_type_declaration().name, + dimension_name: "Data location".to_owned(), + dimension_keys, + measures, + default_measure: None, + }, + operators, + }) + } } impl SimulatorUiAnalyzer { diff --git a/examples/simulator/server/build.rs b/examples/simulator/server/build.rs index b8c5df33b..5f643a091 100644 --- a/examples/simulator/server/build.rs +++ b/examples/simulator/server/build.rs @@ -1,10 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use quent_query_engine_ui::DataFlowTimelineBinned; use quent_query_engine_ui::{OperatorFilter, QueryBundle, QueryFilter}; use quent_simulator_ui::EntityRef; use quent_ui::entities::{request::EntityListRequest, response::EntityListResponse}; use quent_ui::timeline::{ + categorical::CategoricalTimelineRequest, request::{BulkTimelineRequest, SingleTimelineRequest}, response::{BulkTimelinesResponse, SingleTimelineResponse}, }; @@ -22,6 +24,8 @@ fn main() -> Result<(), Box> { ::export_all(&cfg)?; as TS>::export_all(&cfg)?; ::export_all(&cfg)?; + as TS>::export_all(&cfg)?; + ::export_all(&cfg)?; as TS>::export_all(&cfg)?; ::export_all(&cfg)?; diff --git a/examples/simulator/server/ts-bindings/CategoricalDecl.ts b/examples/simulator/server/ts-bindings/CategoricalDecl.ts new file mode 100644 index 000000000..bc2ab9efe --- /dev/null +++ b/examples/simulator/server/ts-bindings/CategoricalDecl.ts @@ -0,0 +1,36 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DimensionKeyDecl } from "./DimensionKeyDecl"; +import type { MeasureDecl } from "./MeasureDecl"; + +/** + * Presentation metadata for a categorical timeline, declared by the + * downstream analyzer. + * + * Dimension keys are expected to be a small enumerable set; unbounded key + * cardinality is a downstream misuse. + */ +export type CategoricalDecl = { +/** + * The FSM type whose states are broken down. References an entry in the + * application's FSM type declarations (e.g. `QueryBundle` fsm_types) for + * the state graph, names, and ordering. + */ +entity_type_name: string, +/** + * Display name of the dimension, e.g. an application may use a dimension + * describing where an entity's data resides. + */ +dimension_name: string, +/** + * The dimension keys in stable stacking/legend order. + */ +dimension_keys: Array, +/** + * The measures present in this response. + */ +measures: Array, +/** + * The measure the UI should select by default; must name an entry in + * `measures`. `None` means the first declared measure. + */ +default_measure: string | null, }; diff --git a/examples/simulator/server/ts-bindings/CategoricalSeries.ts b/examples/simulator/server/ts-bindings/CategoricalSeries.ts new file mode 100644 index 000000000..7aeb19dd0 --- /dev/null +++ b/examples/simulator/server/ts-bindings/CategoricalSeries.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Binned values of one categorical timeline series: + * measure name -> state name -> dimension key -> one value per time bin. + * + * Absent inner entries mean all-zero bins. + */ +export type CategoricalSeries = { values: { [key in string]: { [key in string]: { [key in string]: Array } } }, }; diff --git a/examples/simulator/server/ts-bindings/CategoricalTimelineRequest.ts b/examples/simulator/server/ts-bindings/CategoricalTimelineRequest.ts new file mode 100644 index 000000000..5a331c4ad --- /dev/null +++ b/examples/simulator/server/ts-bindings/CategoricalTimelineRequest.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TimelineConfig } from "./TimelineConfig"; + +/** + * Request for a categorical timeline. + */ +export type CategoricalTimelineRequest = { +/** + * Names of the measures to compute. Empty means all declared measures. + */ +measures: Array, +/** + * The configuration of the window and number of bins. + */ +config: TimelineConfig, +/** + * Global application-specific parameters, e.g. filters. + */ +app_params: GlobalParams, }; diff --git a/examples/simulator/server/ts-bindings/DataFlowTimelineBinned.ts b/examples/simulator/server/ts-bindings/DataFlowTimelineBinned.ts new file mode 100644 index 000000000..34306abb0 --- /dev/null +++ b/examples/simulator/server/ts-bindings/DataFlowTimelineBinned.ts @@ -0,0 +1,27 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BinnedSpanSec } from "./BinnedSpanSec"; +import type { CategoricalDecl } from "./CategoricalDecl"; +import type { CategoricalSeries } from "./CategoricalSeries"; + +/** + * A binned data-flow categorical timeline covering every operator of a + * query. Analyzers without data-flow telemetry return + * `AnalyzerError::Unsupported` instead (HTTP 501), which the UI treats as + * "hide the view". + */ +export type DataFlowTimelineBinned = { +/** + * The configuration of the binned timeline. + * + * This may slightly differ from the requested configuration to ensure + * bounds are not exceeded and bin sizes are equal. + */ +config: BinnedSpanSec, +/** + * Presentation metadata declared by the analyzer. + */ +decl: CategoricalDecl, +/** + * Categorical series keyed by operator id. + */ +operators: { [key in string]: CategoricalSeries }, }; diff --git a/examples/simulator/server/ts-bindings/DimensionKeyDecl.ts b/examples/simulator/server/ts-bindings/DimensionKeyDecl.ts new file mode 100644 index 000000000..e52530b68 --- /dev/null +++ b/examples/simulator/server/ts-bindings/DimensionKeyDecl.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One key of the application-defined dimension of a categorical timeline. + */ +export type DimensionKeyDecl = { +/** + * The key used in [`CategoricalSeries::values`]. + */ +key: string, +/** + * Human-friendly display name. + */ +display_name: string, }; diff --git a/examples/simulator/server/ts-bindings/MeasureDecl.ts b/examples/simulator/server/ts-bindings/MeasureDecl.ts new file mode 100644 index 000000000..7bf5cd947 --- /dev/null +++ b/examples/simulator/server/ts-bindings/MeasureDecl.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CapacityKind } from "./CapacityKind"; + +/** + * A measure declared by the downstream analyzer for a categorical timeline, + * e.g. an entity count or a number of bytes. + */ +export type MeasureDecl = { +/** + * Unique name; key into [`CategoricalSeries::values`]. + */ +name: string, +/** + * Human-friendly display name. + */ +display_name: string, +/** + * Key into the application's quantity specs map for unit formatting. + */ +quantity: string, +/** + * Display semantics of a bin value (Occupancy = time-weighted level). + */ +kind: CapacityKind, }; diff --git a/ui/packages/@quent/client/src/api.test.ts b/ui/packages/@quent/client/src/api.test.ts new file mode 100644 index 000000000..c00149b87 --- /dev/null +++ b/ui/packages/@quent/client/src/api.test.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { DataFlowTimelineBinned, TimelineConfig } from '@quent/utils'; +import { fetchDataFlow } from './api'; + +const CONFIG: TimelineConfig = { start: 0, end: 8, num_bins: 4 }; + +const BINNED: DataFlowTimelineBinned = { + config: { span: { start: 0, end: 8 }, bin_duration: 2, num_bins: 4 }, + decl: { + entity_type_name: 'Task', + dimension_name: 'Data location', + dimension_keys: [{ key: 'memory', display_name: 'Memory' }], + measures: [{ name: 'tasks', display_name: 'Tasks', quantity: 'unit', kind: 'Occupancy' }], + default_measure: null, + }, + operators: { + 'op-1': { values: { tasks: { queueing: { memory: [1, 2, 0, 0] } } } }, + }, +}; + +function stubFetch(response: Response) { + const fetchMock = vi.fn().mockResolvedValue(response); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('fetchDataFlow', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('resolves the binned timeline on a 200 response', async () => { + stubFetch(new Response(JSON.stringify(BINNED), { status: 200 })); + await expect(fetchDataFlow('e-1', 'q-1', CONFIG)).resolves.toEqual(BINNED); + }); + + it('resolves to the null sentinel on HTTP 501 (unsupported analyzer)', async () => { + stubFetch(new Response(null, { status: 501, statusText: 'Not Implemented' })); + await expect(fetchDataFlow('e-1', 'q-1', CONFIG)).resolves.toBeNull(); + }); + + it('still rejects on other non-ok statuses', async () => { + stubFetch(new Response(null, { status: 500, statusText: 'Internal Server Error' })); + await expect(fetchDataFlow('e-1', 'q-1', CONFIG)).rejects.toThrow( + 'API Error: 500 Internal Server Error' + ); + }); + + it('POSTs a CategoricalTimelineRequest to the data-flow endpoint', async () => { + const fetchMock = stubFetch(new Response(JSON.stringify(BINNED), { status: 200 })); + await fetchDataFlow('e-1', 'q-1', CONFIG, ['tasks']); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/engines/e-1/timeline/data-flow'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + measures: ['tasks'], + config: CONFIG, + app_params: { query_id: 'q-1' }, + }), + }) + ); + }); +}); diff --git a/ui/packages/@quent/client/src/api.ts b/ui/packages/@quent/client/src/api.ts index 9306010a7..88e4323a7 100644 --- a/ui/packages/@quent/client/src/api.ts +++ b/ui/packages/@quent/client/src/api.ts @@ -11,10 +11,13 @@ import type { SingleTimelineRequest, SingleTimelineResponse, BulkTimelineRequest, + CategoricalTimelineRequest, + DataFlowTimelineBinned, QueryFilter, OperatorFilter, EntityRef, Engine, + TimelineConfig, } from '@quent/utils'; interface ApiFetchOptions { @@ -23,11 +26,12 @@ interface ApiFetchOptions { } /** - * Generic API fetch helper — internal, not exported from package barrel + * Issues the request and returns the raw {@link Response} — internal helper + * for fetchers that need to inspect the status code themselves. * @param endpoint - API endpoint to call * @param options - Optional params and fetch options */ -async function apiFetch(endpoint: string, options?: ApiFetchOptions): Promise { +async function apiFetchResponse(endpoint: string, options?: ApiFetchOptions): Promise { const { params, fetchOptions } = options ?? {}; const searchParams = params ? `?${new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)]))}` @@ -45,7 +49,16 @@ async function apiFetch(endpoint: string, options?: ApiFetchOptions): Promise }; } - const response = await fetch(url, { ...defaultOptions, ...fetchOptions }); + return fetch(url, { ...defaultOptions, ...fetchOptions }); +} + +/** + * Generic API fetch helper — internal, not exported from package barrel + * @param endpoint - API endpoint to call + * @param options - Optional params and fetch options + */ +async function apiFetch(endpoint: string, options?: ApiFetchOptions): Promise { + const response = await apiFetchResponse(endpoint, options); if (!response.ok) { throw new Error(`API Error: ${response.status} ${response.statusText}`); @@ -104,3 +117,34 @@ export async function fetchBulkTimelines( }, }); } + +/** + * Fetch the data-flow categorical timeline for a query (all operators in one + * response). Resolves to `null` when the engine's analyzer does not implement + * the data-flow protocol (HTTP 501) — an expected "feature unavailable" + * outcome, not an error, so react-query settles instead of retrying. + * @param measures - Measure names to compute; empty means all declared measures. + */ +export async function fetchDataFlow( + engineId: string, + queryId: string, + config: TimelineConfig, + measures: string[] = [] +): Promise { + const request: CategoricalTimelineRequest = { + measures, + config, + app_params: { query_id: queryId }, + }; + const response = await apiFetchResponse(`/engines/${engineId}/timeline/data-flow`, { + fetchOptions: { + method: 'POST', + body: JSON.stringify(request), + }, + }); + if (response.status === 501) return null; + if (!response.ok) { + throw new Error(`API Error: ${response.status} ${response.statusText}`); + } + return parseJsonWithBigInt(await response.text()); +} diff --git a/ui/packages/@quent/client/src/dataFlow.ts b/ui/packages/@quent/client/src/dataFlow.ts new file mode 100644 index 000000000..3a6d1a7d2 --- /dev/null +++ b/ui/packages/@quent/client/src/dataFlow.ts @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { keepPreviousData, queryOptions, useQuery } from '@tanstack/react-query'; +import type { TimelineConfig } from '@quent/utils'; +import { fetchDataFlow } from './api'; +import { DEFAULT_STALE_TIME } from './constants'; + +interface DataFlowParams { + engineId: string; + queryId: string; + /** Window (seconds relative to the query epoch) and bin count. */ + config: TimelineConfig; + /** Measure names to compute; empty/omitted means all declared measures. */ + measures?: string[]; +} + +export const dataFlowQueryOptions = ( + { engineId, queryId, config, measures = [] }: DataFlowParams, + options?: { staleTime?: number; enabled?: boolean } +) => + queryOptions({ + queryKey: ['dataFlow', engineId, queryId, config.start, config.end, config.num_bins, measures], + // `fetchDataFlow` RESOLVES to `null` on HTTP 501 (analyzer without + // data-flow support) rather than throwing, so react-query settles the + // query as "unavailable" — no retries, no error noise. + queryFn: () => fetchDataFlow(engineId, queryId, config, measures), + staleTime: options?.staleTime ?? DEFAULT_STALE_TIME, + enabled: options?.enabled ?? true, + // Keep the previous window's data while a zoom-triggered refetch is in + // flight so the DAG overlay doesn't flicker to empty. + placeholderData: keepPreviousData, + }); + +export const useDataFlow = ( + params: DataFlowParams, + options?: { staleTime?: number; enabled?: boolean } +) => useQuery(dataFlowQueryOptions(params, options)); diff --git a/ui/packages/@quent/client/src/index.ts b/ui/packages/@quent/client/src/index.ts index 937b8538f..47919a24f 100644 --- a/ui/packages/@quent/client/src/index.ts +++ b/ui/packages/@quent/client/src/index.ts @@ -13,6 +13,7 @@ export { fetchListQueries, fetchSingleTimeline, fetchBulkTimelines, + fetchDataFlow, } from './api'; // queryOptions factories @@ -22,6 +23,7 @@ export { queryGroupsQueryOptions } from './queryGroups'; export { queriesQueryOptions } from './queries'; export { singleTimelineQueryOptions } from './timeline'; export { bulkTimelineQueryOptions } from './bulkTimelines'; +export { dataFlowQueryOptions } from './dataFlow'; // Hooks export { useQueryBundle } from './queryBundle'; @@ -29,3 +31,4 @@ export { useEngines } from './engines'; export { useQueryGroups } from './queryGroups'; export { useQueries } from './queries'; export { useTimeline } from './timeline'; +export { useDataFlow } from './dataFlow'; diff --git a/ui/packages/@quent/components/src/dag/ColorDot.tsx b/ui/packages/@quent/components/src/dag/ColorDot.tsx new file mode 100644 index 000000000..4669ab1aa --- /dev/null +++ b/ui/packages/@quent/components/src/dag/ColorDot.tsx @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Small square color swatch used as an inline legend marker. */ +export const ColorDot = ({ color }: { color: string }) => ( + +); diff --git a/ui/packages/@quent/components/src/dag/DAGChart.tsx b/ui/packages/@quent/components/src/dag/DAGChart.tsx index 3f77a5fac..8e57ba05a 100644 --- a/ui/packages/@quent/components/src/dag/DAGChart.tsx +++ b/ui/packages/@quent/components/src/dag/DAGChart.tsx @@ -40,6 +40,8 @@ import { useSetSelectedNodeData, useSetDagDisplayedNodeIds, useSelectedDagLayoutDirection, + useDataFlowEnabled, + useDataFlowMeta, } from '@quent/hooks'; import { calculateLayout, NODE_LAYOUT_WIDTH } from './layout'; import type { DAGData } from '../services/query-plan/types'; @@ -280,6 +282,11 @@ const FlowLayout = ({ const setSelectedNodeData = useSetSelectedNodeData(); const selectedNodeIds = useSelectedNodeIds(); const [layoutDirection] = useSelectedDagLayoutDirection(); + const dataFlowEnabled = useDataFlowEnabled(); + const dataFlowMeta = useDataFlowMeta(); + // Stable boolean: only flips on availability/toggle, not on zoom refetches, + // so toggling the overlay relayouts exactly once. + const flowBarVisible = dataFlowEnabled && dataFlowMeta != null; const hasUserInteracted = useRef(false); // Sync controlled selectedNodeIds into the atom when provided @@ -330,6 +337,7 @@ const FlowLayout = ({ layoutDirection, isDark, baseColor: operatorColorMap.get(node.type.toLowerCase()), + flowBarVisible, }, style: { width: NODE_LAYOUT_WIDTH, @@ -352,7 +360,7 @@ const FlowLayout = ({ })); return { flowNodes, flowEdges }; - }, [data, isDark, operatorColorMap, layoutDirection]); + }, [data, isDark, operatorColorMap, layoutDirection, flowBarVisible]); const handleNodeClick = useCallback( (_event: MouseEvent, node: Node): void => { @@ -406,10 +414,15 @@ const FlowLayout = ({ // Calculate and apply layout useLayoutEffect(() => { hasUserInteracted.current = false; + // calculateLayout is async: rapid dependency changes (e.g. flowBarVisible + // toggles) can interleave calls, so discard results from stale runs + // instead of letting them overwrite a newer layout. + let cancelled = false; const applyLayout = async () => { const { flowNodes, flowEdges } = convertToReactFlow(); const layoutResult = await calculateLayout(flowNodes, flowEdges, layoutDirection); + if (cancelled) return; setNodes(layoutResult.nodes); setEdges(layoutResult.edges); @@ -419,6 +432,9 @@ const FlowLayout = ({ }; applyLayout(); + return () => { + cancelled = true; + }; }, [data, convertToReactFlow, fitView, setNodes, setEdges, layoutDirection]); return ( diff --git a/ui/packages/@quent/components/src/dag/DAGControls.tsx b/ui/packages/@quent/components/src/dag/DAGControls.tsx index f11e26d34..2a5de1e98 100644 --- a/ui/packages/@quent/components/src/dag/DAGControls.tsx +++ b/ui/packages/@quent/components/src/dag/DAGControls.tsx @@ -10,14 +10,34 @@ import { useSelectedDagLayoutDirection, useNodeColorPalette, useEdgeColorPalette, + useDataFlowEnabled, + useSetDataFlowEnabled, + useDataFlowMeta, + useSelectedDataFlowMeasure, + useSetSelectedDataFlowMeasure, + useDataFlowLabelMeasure, + useSetDataFlowLabelMeasure, + useSetDataFlowSelectedDimensions, + resolveDataFlowMeasure, } from '@quent/hooks'; import { + cn, NODE_LABEL_FIELD, DAG_LAYOUT_DIRECTION, type NodeLabelField, type DagLayoutDirection, } from '@quent/utils'; -import { Palette, Spline, Brush, Type, ArrowUpDown } from 'lucide-react'; +import { + Palette, + Spline, + Brush, + Type, + ArrowUpDown, + Activity, + Gauge, + Tags, + Layers, +} from 'lucide-react'; import { PalettePicker } from './PalettePicker'; interface DAGControlsProps { @@ -47,10 +67,45 @@ export const DAGControls = ({ operatorStatFields, portStatFields, isDark }: DAGC const [layoutDirection, setLayoutDirection] = useSelectedDagLayoutDirection(); const [nodePalette, setNodePalette] = useNodeColorPalette(); const [edgePalette, setEdgePalette] = useEdgeColorPalette(); + const dataFlowEnabled = useDataFlowEnabled(); + const setDataFlowEnabled = useSetDataFlowEnabled(); + const dataFlowMeta = useDataFlowMeta(); + const selectedDataFlowMeasure = useSelectedDataFlowMeasure(); + const setSelectedDataFlowMeasure = useSetSelectedDataFlowMeasure(); + const dataFlowLabelMeasure = useDataFlowLabelMeasure(); + const setDataFlowLabelMeasure = useSetDataFlowLabelMeasure(); + const setDataFlowSelectedDimensions = useSetDataFlowSelectedDimensions(); const operatorOptions: SelectFieldOption[] = operatorStatFields.map(f => ({ value: f })); const portOptions: SelectFieldOption[] = portStatFields.map(f => ({ value: f })); + const measureOptions: SelectFieldOption[] = (dataFlowMeta?.decl.measures ?? []).map(m => ({ + value: m.name, + label: m.display_name, + })); + const effectiveMeasure = dataFlowMeta + ? resolveDataFlowMeasure(selectedDataFlowMeasure, dataFlowMeta.decl) + : null; + + // Tier (dimension-key) selection chips. `dimensionSelection` on the meta + // is the resolved selection (never empty); the LAST selected tier cannot + // be unchecked — "nothing selected" is not a state, and stale selections + // are reset to "all" by useDataFlowSync on a decl key-set change. + const dimensionKeys = dataFlowMeta?.decl.dimension_keys ?? []; + const dimensionSelection = dataFlowMeta?.dimensionSelection; + const toggleDimension = (key: string) => { + if (!dimensionSelection) return; + const next = new Set(dimensionSelection); + if (next.has(key)) { + if (next.size <= 1) return; + next.delete(key); + } else { + next.add(key); + } + // Normalize a full selection back to `null` (= all, survives new keys). + setDataFlowSelectedDimensions(next.size === dimensionKeys.length ? null : next); + }; + return (
@@ -112,6 +167,77 @@ export const DAGControls = ({ operatorStatFields, portStatFields, isDark }: DAGC clearable={false} triggerClassName="h-6 text-xs" /> + {dataFlowMeta && ( + + )} + {dataFlowMeta && measureOptions.length > 1 && ( + v && setSelectedDataFlowMeasure(v)} + placeholder="Measure" + clearable={false} + triggerClassName="h-6 text-xs" + /> + )} + {dataFlowMeta && measureOptions.length > 1 && ( + + )} + {dataFlowMeta && dimensionSelection && dimensionKeys.length > 1 && ( +
+ + + {dataFlowMeta.decl.dimension_name} + +
+ {dimensionKeys.map(k => { + const checked = dimensionSelection.has(k.key); + const isLastChecked = checked && dimensionSelection.size <= 1; + return ( + + ); + })} +
+
+ )}
); diff --git a/ui/packages/@quent/components/src/dag/DAGLegend.tsx b/ui/packages/@quent/components/src/dag/DAGLegend.tsx index 41545df76..f1e2fcabf 100644 --- a/ui/packages/@quent/components/src/dag/DAGLegend.tsx +++ b/ui/packages/@quent/components/src/dag/DAGLegend.tsx @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { useMemo } from 'react'; import { Panel } from '@xyflow/react'; import { useNodeColoringValue, @@ -9,9 +10,18 @@ import { useEdgeColorPalette, useSelectedColorField, useSelectedEdgeColorField, + useDataFlowEnabled, + useDataFlowMeta, } from '@quent/hooks'; -import { getLegendGradientStops } from '@quent/utils'; +import { + cn, + createCapacitiesColorFn, + createDataFlowStateColorFn, + getLegendGradientStops, + type PaletteTheme, +} from '@quent/utils'; import { inferFieldFormatter } from '@quent/utils'; +import { DataFlowTierLegend } from './DataFlowTierLegend'; import type { NodeColoring, EdgeColoring } from '../services/query-plan/types'; import type { ContinuousPaletteName } from '@quent/utils'; @@ -49,9 +59,27 @@ const ContinuousLegend = ({ field, min, max, palette, isDark }: ContinuousLegend interface CategoricalLegendProps { field: string; categoryMap: Map; + /** + * Labels rendered greyed-out (e.g. deselected data-flow tiers) — still + * listed so the user sees what is being filtered out. + */ + dimmedLabels?: ReadonlySet; + /** + * Per-entry value suffix (keyed by label), rendered after the label as + * "· " — e.g. the tier's total at the playhead bin. Labels + * without an entry get no suffix. Dimmed entries keep their suffix + * (dimmed along with the rest of the entry, but not struck through). + */ + entrySuffixes?: ReadonlyMap; } -const CategoricalLegend = ({ field, categoryMap }: CategoricalLegendProps) => { +/** Shared category-swatch legend group (also used by `DataFlowTierLegend`). */ +export const CategoricalLegend = ({ + field, + categoryMap, + dimmedLabels, + entrySuffixes, +}: CategoricalLegendProps) => { const entries = [...categoryMap.entries()].slice(0, MAX_CATEGORICAL_ENTRIES); const truncated = categoryMap.size > MAX_CATEGORICAL_ENTRIES; return ( @@ -60,17 +88,38 @@ const CategoricalLegend = ({ field, categoryMap }: CategoricalLegendProps) => { {field}
- {entries.map(([label, color]) => ( -
- - - {label} - -
- ))} + {entries.map(([label, color]) => { + const dimmed = dimmedLabels?.has(label) ?? false; + const suffix = entrySuffixes?.get(label); + return ( +
+ + + {label} + + {suffix != null && ( + + · {suffix} + + )} +
+ ); + })} {truncated && ( +{categoryMap.size - MAX_CATEGORICAL_ENTRIES} more @@ -146,11 +195,49 @@ export const DAGLegend = ({ isDark }: DAGLegendProps) => { const [edgePalette] = useEdgeColorPalette(); const [nodeField] = useSelectedColorField(); const [edgeField] = useSelectedEdgeColorField(); + const dataFlowEnabled = useDataFlowEnabled(); + const dataFlowMeta = useDataFlowMeta(); + const paletteTheme: PaletteTheme = isDark ? 'dark' : 'light'; + + // Data-flow overlay legends: FSM states (colored like the timeline view) + // and the server-declared dimension keys (colored like capacity series). + const dataFlowStateLegend = useMemo(() => { + if (!dataFlowMeta) return null; + const colorFn = createDataFlowStateColorFn( + dataFlowMeta.fsmType, + dataFlowMeta.stateNames, + paletteTheme + ); + return new Map(dataFlowMeta.stateNames.map(state => [state, colorFn(state)])); + }, [dataFlowMeta, paletteTheme]); + + const dataFlowDimensionLegend = useMemo(() => { + if (!dataFlowMeta) return null; + const keys = dataFlowMeta.decl.dimension_keys; + const colorFn = createCapacitiesColorFn( + keys.map(k => k.key), + paletteTheme + ); + return new Map(keys.map(k => [k.display_name, colorFn(k.key)])); + }, [dataFlowMeta, paletteTheme]); + + // Deselected tiers stay listed but greyed-out, so the user can see what + // the tier filter is currently hiding. + const dimmedDimensionLabels = useMemo(() => { + if (!dataFlowMeta) return undefined; + return new Set( + dataFlowMeta.decl.dimension_keys + .filter(k => !dataFlowMeta.dimensionSelection.has(k.key)) + .map(k => k.display_name) + ); + }, [dataFlowMeta]); const hasNode = !!nodeColoring && !!nodeField; const hasEdge = !!edgeColoring && !!edgeField; + const hasDataFlow = + dataFlowEnabled && !!dataFlowMeta && !!dataFlowStateLegend && !!dataFlowDimensionLegend; - if (!hasNode && !hasEdge) return null; + if (!hasNode && !hasEdge && !hasDataFlow) return null; return ( @@ -168,6 +255,20 @@ export const DAGLegend = ({ isDark }: DAGLegendProps) => { palette={edgePalette} isDark={isDark} /> + {(hasNode || hasEdge) && hasDataFlow &&
} + {hasDataFlow && ( + <> + + + + )}
); diff --git a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx index 3ec24e0b8..408cc73c8 100644 --- a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx +++ b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx @@ -3,15 +3,29 @@ import { useEffect, useState } from 'react'; import { ChevronUp, ChevronDown } from 'lucide-react'; -import { useSelectedNodeData } from '@quent/hooks'; +import { + useSelectedNodeData, + useDataFlowEnabled, + useDataFlowMeta, + useDataFlowFrame, +} from '@quent/hooks'; import { DataText } from '../ui/data-text'; import { thinScrollbarClass } from '../ui/thin-scroll'; import { inferFieldFormatter } from '@quent/utils'; +import { DataFlowMatrix } from './DataFlowMatrix'; -export const DAGNodeInfoPanel = () => { +export const DAGNodeInfoPanel = ({ isDark = false }: { isDark?: boolean }) => { const selectedNodeData = useSelectedNodeData(); + const dataFlowEnabled = useDataFlowEnabled(); + const dataFlowMeta = useDataFlowMeta(); + const dataFlowFrame = useDataFlowFrame(); const [isExpanded, setIsExpanded] = useState(false); + const operatorFrame = + dataFlowEnabled && selectedNodeData && dataFlowMeta && dataFlowFrame + ? dataFlowFrame.perOperator.get(selectedNodeData.nodeId) + : undefined; + useEffect(() => { setIsExpanded(!!selectedNodeData); }, [selectedNodeData?.nodeId]); @@ -49,6 +63,14 @@ export const DAGNodeInfoPanel = () => { {isExpanded && selectedNodeData && (
+ {operatorFrame && dataFlowMeta && dataFlowFrame && ( + + )}
ID: diff --git a/ui/packages/@quent/components/src/dag/DagPlayhead.tsx b/ui/packages/@quent/components/src/dag/DagPlayhead.tsx new file mode 100644 index 000000000..3c79452f2 --- /dev/null +++ b/ui/packages/@quent/components/src/dag/DagPlayhead.tsx @@ -0,0 +1,258 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Pause, Play } from 'lucide-react'; +import { cn, formatDurationForWindow } from '@quent/utils'; +import { + useDataFlowEnabled, + useDataFlowMeta, + usePlayheadTimeS, + useSetPlayheadTimeS, +} from '@quent/hooks'; +import { broadcastSyncedPointer, hideSyncedPointer, nanosToMs } from '../lib/timeline.utils'; + +/** Interval between play ticks; each tick advances the playhead by one bin. */ +const PLAY_INTERVAL_MS = 100; +const KEYBOARD_STEP_BINS = 1; +const KEYBOARD_FAST_STEP_BINS = 10; + +interface DagPlayheadProps { + /** + * Query epoch (ns since Unix epoch). Used to broadcast a synced axis + * pointer to the timeline charts while scrubbing/playing. + */ + startTimeUnixNs: bigint; + className?: string; +} + +function formatTimeLabel(timeS: number, windowS: number): string { + if (timeS === 0) return '0s'; + return formatDurationForWindow(timeS * 1000, Math.max(windowS, Number.EPSILON) * 1000); +} + +/** + * Time slider (playhead) for the DAG data-flow overlay. Plain DOM (not + * ECharts): a playhead is a point, not a range brush. Writes the playhead + * atom (rAF-throttled while dragging); `useDataFlowSync` turns playhead + * changes into per-bin frames. Renders nothing when the feature is + * unavailable or disabled. + */ +export function DagPlayhead({ startTimeUnixNs, className }: DagPlayheadProps) { + const enabled = useDataFlowEnabled(); + const meta = useDataFlowMeta(); + const playheadTimeS = usePlayheadTimeS(); + const setPlayheadTimeS = useSetPlayheadTimeS(); + const [isPlaying, setIsPlaying] = useState(false); + + const trackRef = useRef(null); + const rafRef = useRef(null); + const pendingClientXRef = useRef(null); + const playheadRef = useRef(playheadTimeS); + playheadRef.current = playheadTimeS; + + const startTimeMs = useMemo(() => nanosToMs(startTimeUnixNs), [startTimeUnixNs]); + + const bin = meta?.bin ?? null; + + const clampTime = useCallback( + (timeS: number): number => { + if (!bin) return timeS; + return Math.min(Math.max(timeS, bin.startS), bin.endS); + }, + [bin] + ); + + const applyClientX = useCallback( + (clientX: number) => { + const track = trackRef.current; + if (!track || !bin) return; + const rect = track.getBoundingClientRect(); + if (rect.width <= 0) return; + const t = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + const timeS = bin.startS + t * (bin.endS - bin.startS); + setPlayheadTimeS(timeS); + broadcastSyncedPointer(startTimeMs + timeS * 1000); + }, + [bin, setPlayheadTimeS, startTimeMs] + ); + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId); + applyClientX(event.clientX); + }, + [applyClientX] + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; + pendingClientXRef.current = event.clientX; + if (rafRef.current != null) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + if (pendingClientXRef.current != null) applyClientX(pendingClientXRef.current); + pendingClientXRef.current = null; + }); + }, + [applyClientX] + ); + + const handlePointerEnd = useCallback((event: React.PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + hideSyncedPointer(); + }, []); + + const stepBy = useCallback( + (bins: number) => { + if (!bin) return; + const current = playheadRef.current ?? bin.startS; + setPlayheadTimeS(clampTime(current + bins * bin.binDurationS)); + }, + [bin, clampTime, setPlayheadTimeS] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!bin) return; + const step = event.shiftKey ? KEYBOARD_FAST_STEP_BINS : KEYBOARD_STEP_BINS; + switch (event.key) { + case 'ArrowLeft': + case 'ArrowDown': + stepBy(-step); + break; + case 'ArrowRight': + case 'ArrowUp': + stepBy(step); + break; + case 'Home': + setPlayheadTimeS(bin.startS); + break; + case 'End': + setPlayheadTimeS(bin.endS); + break; + default: + return; + } + event.preventDefault(); + }, + [bin, stepBy, setPlayheadTimeS] + ); + + const togglePlay = useCallback(() => { + if (!bin) return; + setIsPlaying(playing => { + if (!playing) { + // Restart from the window start when play is pressed at the end. + const current = playheadRef.current ?? bin.startS; + if (current >= bin.endS) setPlayheadTimeS(bin.startS); + } + return !playing; + }); + }, [bin, setPlayheadTimeS]); + + // Stop playback when the overlay is disabled or the bin metadata goes + // away: the component stays mounted while rendering null, so a live play + // interval would otherwise keep advancing the playhead and broadcasting + // the synced crosshair invisibly. + useEffect(() => { + if (enabled && bin) return; + setIsPlaying(false); + hideSyncedPointer(); + }, [enabled, bin]); + + // Advance one bin per tick while playing; stop at the window end. + useEffect(() => { + if (!isPlaying || !bin) return; + const { startS, endS, binDurationS } = bin; + const id = window.setInterval(() => { + const current = playheadRef.current ?? startS; + const next = Math.min(current + binDurationS, endS); + setPlayheadTimeS(next); + broadcastSyncedPointer(startTimeMs + next * 1000); + if (next >= endS) setIsPlaying(false); + }, PLAY_INTERVAL_MS); + return () => window.clearInterval(id); + }, [isPlaying, bin, setPlayheadTimeS, startTimeMs]); + + // Clear the synced crosshair when playback stops. + useEffect(() => { + if (!isPlaying) hideSyncedPointer(); + }, [isPlaying]); + + // Cleanup on unmount: pending rAF and any lingering crosshair. + useEffect(() => { + return () => { + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + hideSyncedPointer(); + }; + }, []); + + if (!enabled || !meta || !bin) return null; + + const windowS = Math.max(bin.endS - bin.startS, Number.EPSILON); + const timeS = clampTime(playheadTimeS ?? bin.startS); + const positionPct = ((timeS - bin.startS) / windowS) * 100; + const currentLabel = formatTimeLabel(timeS, windowS); + + return ( +
+ + + {formatTimeLabel(bin.startS, windowS)} + +
+
+
+
+
+ + {formatTimeLabel(bin.endS, windowS)} + + + {currentLabel} + +
+ ); +} diff --git a/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx b/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx new file mode 100644 index 000000000..d41ecc153 --- /dev/null +++ b/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo } from 'react'; +import { + formatDataFlowValue, + type DataFlowFrame, + type DataFlowMeta, + type DataFlowOperatorFrame, +} from '@quent/hooks'; +import { + createCapacitiesColorFn, + createDataFlowStateColorFn, + formatDuration, + type PaletteTheme, +} from '@quent/utils'; +import { DataText } from '../ui/data-text'; +import { ColorDot } from './ColorDot'; + +/** + * State × dimension matrix of the data-flow distribution for the selected + * operator at the playhead's bin. Values are span-weighted per-bin averages + * ("during this bin"), so fractional counts are expected. Columns are + * filtered to the SELECTED dimension keys (tiers) — deselected tiers are + * zero in the frame anyway, so hiding their columns loses nothing. + */ +export const DataFlowMatrix = ({ + meta, + frame, + operatorFrame, + isDark, +}: { + meta: DataFlowMeta; + frame: DataFlowFrame; + operatorFrame: DataFlowOperatorFrame; + isDark: boolean; +}) => { + const paletteTheme: PaletteTheme = isDark ? 'dark' : 'light'; + const allDimensionKeys = meta.decl.dimension_keys; + // Keep original decl-order indices — the frame's matrix/byDimension are + // indexed by declaration order, not by the filtered column order. + const dimensionColumns = useMemo( + () => + allDimensionKeys + .map((key, index) => ({ key, index })) + .filter(({ key }) => meta.dimensionSelection.has(key.key)), + [allDimensionKeys, meta.dimensionSelection] + ); + const stateColor = useMemo( + () => createDataFlowStateColorFn(meta.fsmType, meta.stateNames, paletteTheme), + [meta, paletteTheme] + ); + const dimensionColor = useMemo( + () => + createCapacitiesColorFn( + allDimensionKeys.map(k => k.key), + paletteTheme + ), + [allDimensionKeys, paletteTheme] + ); + + const fmt = (value: number) => formatDataFlowValue(value, frame.measure, meta); + const measureDecl = meta.decl.measures.find(m => m.name === frame.measure); + + return ( +
+
+ Data flow @ {formatDuration(frame.timeS * 1000)} + + {' '} + · {measureDecl?.display_name ?? frame.measure} during this bin + +
+ + + + + {dimensionColumns.map(({ key: k }) => ( + + ))} + + + + + {meta.stateNames.map((state, stateIndex) => ( + + + {dimensionColumns.map(({ key: k, index: dimensionIndex }) => ( + + ))} + + + ))} + + + {dimensionColumns.map(({ key: k, index: dimensionIndex }) => ( + + ))} + + + +
+ State / {meta.decl.dimension_name} + + + + {k.display_name} + + + Total +
+ + + {state} + + + + {fmt(operatorFrame.matrix[stateIndex]?.[dimensionIndex] ?? 0)} + + + {fmt(operatorFrame.byState[stateIndex] ?? 0)} +
+ Total + + {fmt(operatorFrame.byDimension[dimensionIndex] ?? 0)} + + {fmt(operatorFrame.total)} +
+
+ ); +}; diff --git a/ui/packages/@quent/components/src/dag/DataFlowTierLegend.tsx b/ui/packages/@quent/components/src/dag/DataFlowTierLegend.tsx new file mode 100644 index 000000000..4ded47996 --- /dev/null +++ b/ui/packages/@quent/components/src/dag/DataFlowTierLegend.tsx @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { memo, useMemo } from 'react'; +import { useDataFlowFrame, formatDataFlowValueCompact, type DataFlowMeta } from '@quent/hooks'; +import { CategoricalLegend } from './DAGLegend'; + +interface DataFlowTierLegendProps { + meta: DataFlowMeta; + /** Tier display name -> swatch color (see `dataFlowDimensionLegend`). */ + categoryMap: Map; + dimmedLabels?: ReadonlySet; +} + +/** + * Dimension (tier) group of the data-flow legend, annotating each tier with + * its TOTAL at the playhead's bin — summed over all operators and states — + * in the current flow measure (e.g. "GPU-0 · 12.4GiB" = total memory held + * by that tier at this point in time). Zero totals get no suffix (matching + * the in-bar labels, which hide zeros); deselected tiers keep their totals, + * dimmed with the rest of the entry. + * + * Isolated in a memoized leaf so that only this subtree subscribes to the + * per-scrub-tick frame — the rest of the legend re-renders only when the + * meta (response/tier selection) changes. + */ +export const DataFlowTierLegend = memo(function DataFlowTierLegend({ + meta, + categoryMap, + dimmedLabels, +}: DataFlowTierLegendProps) { + const frame = useDataFlowFrame(); + const entrySuffixes = useMemo(() => { + if (!frame) return undefined; + const totals = frame.dimensionTotalsByMeasure[frame.measure]; + if (!totals) return undefined; + const suffixes = new Map(); + meta.decl.dimension_keys.forEach((k, index) => { + const total = totals[index] ?? 0; + if (total > 0) { + suffixes.set(k.display_name, formatDataFlowValueCompact(total, frame.measure, meta)); + } + }); + return suffixes; + }, [frame, meta]); + return ( + + ); +}); diff --git a/ui/packages/@quent/components/src/index.ts b/ui/packages/@quent/components/src/index.ts index 6180718b8..23b84be29 100644 --- a/ui/packages/@quent/components/src/index.ts +++ b/ui/packages/@quent/components/src/index.ts @@ -101,6 +101,8 @@ export { connectChart, registerAxisPointerSync, unregisterAxisPointerSync, + broadcastSyncedPointer, + hideSyncedPointer, buildBinnedTimelineSeries, buildBulkParamsForItem, buildTimelineMarks, @@ -168,9 +170,11 @@ export { DAGChart } from './dag/DAGChart'; export { DAGControls } from './dag/DAGControls'; export { DAGLegend } from './dag/DAGLegend'; export { DAGNodeInfoPanel } from './dag/DAGNodeInfoPanel'; +export { DagPlayhead } from './dag/DagPlayhead'; // ─── Query-plan components ──────────────────────────────────────────────────── export { QueryPlanNode } from './query-plan/QueryPlanNode'; +export { NodeFlowBar } from './query-plan/NodeFlowBar'; // ─── Resource-tree components ───────────────────────────────────────────────── export { InlineSelector } from './resource-tree/InlineSelector'; diff --git a/ui/packages/@quent/components/src/lib/timeline.utils.ts b/ui/packages/@quent/components/src/lib/timeline.utils.ts index cbe2e1def..c3a56d8c3 100644 --- a/ui/packages/@quent/components/src/lib/timeline.utils.ts +++ b/ui/packages/@quent/components/src/lib/timeline.utils.ts @@ -431,7 +431,7 @@ interface AxisPointerEntry { const axisPointerRegistry = new Set(); let isBroadcasting = false; -function broadcastShowPointer(source: EChartsInstance, timestampMs: number) { +function broadcastShowPointer(source: EChartsInstance | null, timestampMs: number) { if (isBroadcasting) return; isBroadcasting = true; try { @@ -455,7 +455,7 @@ function broadcastShowPointer(source: EChartsInstance, timestampMs: number) { } } -function broadcastHidePointer(source: EChartsInstance) { +function broadcastHidePointer(source: EChartsInstance | null) { if (isBroadcasting) return; isBroadcasting = true; try { @@ -472,6 +472,21 @@ function broadcastHidePointer(source: EChartsInstance) { } } +/** + * Broadcast a synced axis-pointer crosshair at `timestampMs` (epoch ms) to + * every registered timeline chart, without a source chart. Used by the DAG + * playhead so scrubbing/playing draws a crosshair on the right-panel + * timelines with zero React re-renders. + */ +export function broadcastSyncedPointer(timestampMs: number) { + broadcastShowPointer(null, timestampMs); +} + +/** Hide the crosshair broadcast by {@link broadcastSyncedPointer}. */ +export function hideSyncedPointer() { + broadcastHidePointer(null); +} + export interface AxisPointerSyncOptions { /** If false, this chart will not receive showTip when the pointer is synced from another chart (default true). */ receiveShowTip?: boolean; diff --git a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx new file mode 100644 index 000000000..1d864bfff --- /dev/null +++ b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { memo, useMemo } from 'react'; +import { + createCapacitiesColorFn, + createDataFlowStateColorFn, + type PaletteTheme, +} from '@quent/utils'; +import { + useDataFlowFrame, + useDataFlowMeta, + fitDataFlowSegmentLabel, + formatDataFlowValueCompact, +} from '@quent/hooks'; +import { NODE_LAYOUT_WIDTH } from '../dag/layout'; +import { SegmentValueLabel } from './SegmentValueLabel'; + +const BAR_TRANSITION = 'width 120ms linear'; + +/** + * Usable track width in pixels: the node is laid out at a fixed + * {@link NODE_LAYOUT_WIDTH} with `px-4` (16px) padding on each side. Used to + * width-gate in-segment labels without DOM measurement. + */ +const FLOW_TRACK_PX = NODE_LAYOUT_WIDTH - 32; + +/** State colors keyed on the FSM type declaration — matches the timeline view. */ +/** + * Per-node data-flow overlay: a stacked state bar over a stacked + * dimension/tier bar — both 12px with width-gated in-segment value labels — + * plus a tiny totals label covering every declared measure. Widths are + * driven by the bar measure; in-segment labels by `frame.labelMeasure` + * (which follows the bar measure unless the user picked an independent + * one). Only the SELECTED tiers contribute (unselected dimension columns + * are zero in the frame). CRITICAL PERF: this is the only node-level + * subscriber to the frame atom — a scrub tick re-renders these tiny bars, + * not the full `QueryPlanNode`s. + * + * Constant height whether or not the operator has data at the current bin + * (the empty tracks are the placeholders), so scrubbing never causes layout + * churn. Labels are absolutely positioned inside overflow-hidden segments, + * so their appearance never shifts layout. The two bars stay visually + * distinct: FSM state colors on top, capacity/tier colors below, separated + * by a 2px gap. + */ +export const NodeFlowBar = memo( + ({ operatorId, isDark }: { operatorId: string; isDark: boolean }) => { + const meta = useDataFlowMeta(); + const frame = useDataFlowFrame(); + const theme: PaletteTheme = isDark ? 'dark' : 'light'; + + const fsmType = meta?.fsmType ?? null; + const stateNames = meta?.stateNames; + const stateColor = useMemo( + () => createDataFlowStateColorFn(fsmType, stateNames ?? [], theme), + [fsmType, stateNames, theme] + ); + const dimensionKeys = meta?.decl.dimension_keys; + const dimensionColor = useMemo( + () => + createCapacitiesColorFn( + (dimensionKeys ?? []).map(k => k.key), + theme + ), + [dimensionKeys, theme] + ); + + if (!meta || !frame) return null; + + const operatorFrame = frame.perOperator.get(operatorId); + const total = operatorFrame?.total ?? 0; + const hasData = operatorFrame != null && total > 0 && frame.maxTotal > 0; + // Stable scale while scrubbing: filled width is relative to the max + // operator total across ALL bins of the window (frame.maxTotal). + const filledWidth = hasData ? `max(2px, ${(total / frame.maxTotal) * 100}%)` : '0px'; + + // One compact total per declared measure with data at this bin, in + // declaration order — e.g. "3.2 | 45MB" (count | bytes). A pipe, not a + // middot: "121 · 5GB" reads like the decimal "121.5GB". + const operatorTotals = frame.totalsByMeasure.get(operatorId); + const totalsLabel = operatorTotals + ? meta.decl.measures + .filter(m => (operatorTotals[m.name] ?? 0) > 0) + .map(m => formatDataFlowValueCompact(operatorTotals[m.name]!, m.name, meta)) + .join(' | ') + : ''; + + return ( +
+
+
+ {hasData && + meta.stateNames.map((state, stateIndex) => { + const value = operatorFrame.byState[stateIndex] ?? 0; + if (value <= 0) return null; + const color = stateColor(state); + const label = fitDataFlowSegmentLabel( + value, + frame.maxTotal, + frame.measure, + meta, + FLOW_TRACK_PX, + { + value: operatorFrame.labelByState[stateIndex] ?? 0, + measure: frame.labelMeasure, + } + ); + return ( +
+ {label != null && ( + + )} +
+ ); + })} +
+
+
+
+ {hasData && + meta.decl.dimension_keys.map((dimension, dimensionIndex) => { + const value = operatorFrame.byDimension[dimensionIndex] ?? 0; + if (value <= 0) return null; + const color = dimensionColor(dimension.key); + const label = fitDataFlowSegmentLabel( + value, + frame.maxTotal, + frame.measure, + meta, + FLOW_TRACK_PX, + { + value: operatorFrame.labelByDimension[dimensionIndex] ?? 0, + measure: frame.labelMeasure, + } + ); + return ( +
+ {label != null && ( + + )} +
+ ); + })} +
+
+
+ {totalsLabel !== '' ? totalsLabel : '\u00A0'} +
+
+ ); + } +); + +NodeFlowBar.displayName = 'NodeFlowBar'; diff --git a/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx b/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx index a27c49eb0..4eb5330ae 100644 --- a/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx +++ b/ui/packages/@quent/components/src/query-plan/QueryPlanNode.tsx @@ -28,6 +28,7 @@ import { import { parseCustomStatistics } from '../lib/queryBundle.utils'; import { inferFieldFormatter } from '@quent/utils'; import { DataText } from '../ui/data-text'; +import { NodeFlowBar } from './NodeFlowBar'; export interface QueryPlanNodeData extends Record { label: string; @@ -46,6 +47,12 @@ export interface QueryPlanNodeData extends Record { isDark?: boolean; /** Pre-computed collision-free color for this operator type within the current DAG. */ baseColor?: string; + /** + * Whether the data-flow overlay bar is rendered under the node content. + * Injected by `DAGChart` when converting nodes so toggling the overlay + * relayouts exactly once. + */ + flowBarVisible?: boolean; } const nodeVariants = cva( @@ -214,6 +221,8 @@ export const QueryPlanNode = memo(({ data }: { data: QueryPlanNodeData }) => {
)} + {data.flowBarVisible && operatorId && } + {data.hasOutgoing && ( )} diff --git a/ui/packages/@quent/components/src/query-plan/SegmentValueLabel.tsx b/ui/packages/@quent/components/src/query-plan/SegmentValueLabel.tsx new file mode 100644 index 000000000..ddc9487b2 --- /dev/null +++ b/ui/packages/@quent/components/src/query-plan/SegmentValueLabel.tsx @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isLightColor } from '@quent/utils'; + +/** Width-gated value label centered inside an overflow-hidden segment. */ +export const SegmentValueLabel = ({ + label, + segmentColor, + testId, +}: { + label: string; + segmentColor: string; + testId: string; +}) => ( + + {label} + +); diff --git a/ui/packages/@quent/hooks/src/atoms/dataFlow.ts b/ui/packages/@quent/hooks/src/atoms/dataFlow.ts new file mode 100644 index 000000000..a7582cabd --- /dev/null +++ b/ui/packages/@quent/hooks/src/atoms/dataFlow.ts @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// PRIVATE to @quent/hooks — do not export raw atoms (HOOKS-02). +// Consumers use the selector hooks exported from @quent/hooks index.ts. +// +// The raw data-flow response stays in react-query; only the derived +// meta/frame structures live in atoms (written by `useDataFlowSync`). + +import { atom } from 'jotai'; +import type { DataFlowFrame, DataFlowMeta } from '../dataFlow/dataFlow.utils'; + +/** User toggle for the data-flow overlay (defaults to on). */ +export const dataFlowEnabledAtom = atom(true); + +/** + * Playhead time in seconds relative to the query epoch. + * `null` = uninitialized — `useDataFlowSync` snaps it to the window start. + */ +export const playheadTimeSAtom = atom(null); + +/** Selected measure name; `null` falls back to the first declared measure. */ +export const selectedDataFlowMeasureAtom = atom(null); + +/** + * Measure used for the in-segment value labels of the node flow bars, + * independent of the measure that sizes the bars. `null` follows the bar's + * selected measure ({@link selectedDataFlowMeasureAtom}). + */ +export const dataFlowLabelMeasureAtom = atom(null); + +/** + * Dimension keys (tiers) included in the data-flow overlay. `null` = all + * declared keys. Selections that are empty or reference only unknown keys + * are treated as "all" defensively (the DAGControls chips additionally + * prevent unchecking the last selected key). `useDataFlowSync` resets this + * to `null` whenever the declared key set changes (query/engine switch). + */ +export const dataFlowSelectedDimensionsAtom = atom | null>(null); + +/** + * Presentation metadata for the current response (decls, bin config, + * per-measure window max). `null` when the feature is unavailable. + */ +export const dataFlowMetaAtom = atom(null); + +/** + * Frame at the playhead's bin. Only leaf components (NodeFlowBar, info + * panel) subscribe to this — a scrub tick must not re-render DAG nodes. + */ +export const dataFlowFrameAtom = atom(null); diff --git a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts new file mode 100644 index 000000000..27df9d3b4 --- /dev/null +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts @@ -0,0 +1,622 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; +import type { DataFlowTimelineBinned, FsmTypeDecl, QuantitySpec } from '@quent/utils'; +import { + buildDataFlowMeta, + computeWindowMax, + extractBinConfig, + extractDataFlowFrame, + fitDataFlowSegmentLabel, + formatDataFlowValue, + formatDataFlowValueCompact, + isDataFlowAvailable, + normalizeDataFlowResponse, + resolveDataFlowDimensions, + resolveDataFlowLabelMeasure, + resolveDataFlowMeasure, + resolveDataFlowStates, + resolveDataFlowWindow, + timeToBinIndex, + type DataFlowBinConfig, +} from './dataFlow.utils'; + +const NUM_BINS = 4; + +/** 4 bins over [0, 8) seconds; two dimension keys; two measures. */ +function makeBinned(operators: DataFlowTimelineBinned['operators'] = {}): DataFlowTimelineBinned { + return { + config: { + span: { start: 0, end: 8 }, + bin_duration: 2, + num_bins: BigInt(NUM_BINS), + }, + decl: { + entity_type_name: 'Task', + dimension_name: 'Data location', + dimension_keys: [ + { key: 'memory', display_name: 'Memory' }, + { key: 'filesystem', display_name: 'Filesystem' }, + ], + measures: [ + { name: 'tasks', display_name: 'Tasks', quantity: 'unit', kind: 'Occupancy' }, + { name: 'bytes', display_name: 'Bytes', quantity: 'capacity_bytes', kind: 'Occupancy' }, + ], + default_measure: null, + }, + operators, + }; +} + +const OPERATORS: DataFlowTimelineBinned['operators'] = { + 'op-1': { + values: { + tasks: { + queueing: { + memory: [1, 2, 0, 0], + // "filesystem" absent for queueing => zeros + }, + computing: { + memory: [0, 1, 3, 0], + filesystem: [0, 0, 2, 0], + }, + }, + bytes: { + computing: { + memory: [0, 0, 100, 0], + }, + }, + }, + }, + 'op-2': { + values: { + tasks: { + queueing: { + filesystem: [0, 4, 0, 0], + }, + }, + }, + }, +}; + +const FSM_TYPE: FsmTypeDecl = { + name: 'Task', + states: [ + { name: 'queueing', usages: [] }, + { name: 'allocating', usages: [] }, + { name: 'computing', usages: [] }, + ], + transitions: [], +}; + +const UNIT_SPEC: QuantitySpec = { + symbol: '', + singular: 'task', + plural: 'tasks', + occupancy_prefix: 'None', + rate_prefix: 'None', +}; + +const BYTES_SPEC: QuantitySpec = { + symbol: 'B', + singular: 'byte', + plural: 'bytes', + occupancy_prefix: 'Iec', + rate_prefix: 'Si', +}; + +const BIN: DataFlowBinConfig = { startS: 0, endS: 8, binDurationS: 2, numBins: NUM_BINS }; + +describe('normalizeDataFlowResponse', () => { + it('returns null for null/undefined (unsupported analyzer or not loaded)', () => { + expect(normalizeDataFlowResponse(null)).toBeNull(); + expect(normalizeDataFlowResponse(undefined)).toBeNull(); + }); + + it('passes a binned response through unchanged', () => { + const binned = makeBinned(OPERATORS); + expect(normalizeDataFlowResponse(binned)).toBe(binned); + }); +}); + +describe('isDataFlowAvailable', () => { + it('is false for the null sentinel (unsupported analyzer — HTTP 501)', () => { + expect(isDataFlowAvailable(null)).toBe(false); + expect(isDataFlowAvailable(undefined)).toBe(false); + }); + + it('is false for an empty operators map', () => { + expect(isDataFlowAvailable(makeBinned({}))).toBe(false); + }); + + it('is true for a non-empty binned response', () => { + expect(isDataFlowAvailable(makeBinned(OPERATORS))).toBe(true); + }); +}); + +describe('resolveDataFlowWindow', () => { + it('uses the zoom range when valid (end > start)', () => { + expect(resolveDataFlowWindow({ start: 1, end: 3 }, 10)).toEqual({ start: 1, end: 3 }); + }); + + it('falls back to [0, duration] for an unset zoom range', () => { + expect(resolveDataFlowWindow({ start: 0, end: 0 }, 10)).toEqual({ start: 0, end: 10 }); + }); + + it('falls back to [0, duration] for an inverted zoom range', () => { + expect(resolveDataFlowWindow({ start: 5, end: 2 }, 10)).toEqual({ start: 0, end: 10 }); + }); + + it('falls back to [0, duration] when zoom is null', () => { + expect(resolveDataFlowWindow(null, 7)).toEqual({ start: 0, end: 7 }); + }); +}); + +describe('timeToBinIndex', () => { + it('maps times inside the window to their bin', () => { + expect(timeToBinIndex(0, BIN)).toBe(0); + expect(timeToBinIndex(1.9, BIN)).toBe(0); + expect(timeToBinIndex(2, BIN)).toBe(1); + expect(timeToBinIndex(7.5, BIN)).toBe(3); + }); + + it('clamps times before the window start to bin 0', () => { + expect(timeToBinIndex(-5, BIN)).toBe(0); + }); + + it('clamps times at/after the window end to the last bin', () => { + expect(timeToBinIndex(8, BIN)).toBe(NUM_BINS - 1); + expect(timeToBinIndex(100, BIN)).toBe(NUM_BINS - 1); + }); + + it('returns 0 for degenerate bin configs', () => { + expect(timeToBinIndex(3, { startS: 0, endS: 0, binDurationS: 0, numBins: 0 })).toBe(0); + }); +}); + +describe('extractBinConfig', () => { + it('converts num_bins (bigint) to a number', () => { + const bin = extractBinConfig(makeBinned(OPERATORS)); + expect(bin).toEqual({ startS: 0, endS: 8, binDurationS: 2, numBins: NUM_BINS }); + expect(typeof bin.numBins).toBe('number'); + }); +}); + +describe('resolveDataFlowStates', () => { + it('orders states per the FSM declaration, filtered to states present', () => { + // Declared order: queueing, allocating, computing — allocating absent from data. + expect(resolveDataFlowStates(makeBinned(OPERATORS), FSM_TYPE)).toEqual([ + 'queueing', + 'computing', + ]); + }); + + it('falls back to sorted data keys when the declaration is missing', () => { + expect(resolveDataFlowStates(makeBinned(OPERATORS), null)).toEqual(['computing', 'queueing']); + }); + + it('appends undeclared data states after the declared ones, sorted', () => { + const withExtra = makeBinned({ + 'op-1': { + values: { + tasks: { + zz_custom: { memory: [1, 0, 0, 0] }, + queueing: { memory: [1, 0, 0, 0] }, + }, + }, + }, + }); + expect(resolveDataFlowStates(withExtra, FSM_TYPE)).toEqual(['queueing', 'zz_custom']); + }); +}); + +describe('resolveDataFlowDimensions', () => { + const KEYS = ['memory', 'filesystem']; + + it('resolves null to all declared keys', () => { + expect([...resolveDataFlowDimensions(null, KEYS)]).toEqual(KEYS); + }); + + it('resolves an empty selection to all declared keys', () => { + expect([...resolveDataFlowDimensions(new Set(), KEYS)]).toEqual(KEYS); + }); + + it('resolves a fully-stale selection (no declared keys) to all', () => { + expect([...resolveDataFlowDimensions(new Set(['GPU-0', 'HOST']), KEYS)]).toEqual(KEYS); + }); + + it('keeps a valid subset, dropping unknown keys', () => { + expect([...resolveDataFlowDimensions(new Set(['filesystem', 'nope']), KEYS)]).toEqual([ + 'filesystem', + ]); + }); +}); + +describe('computeWindowMax', () => { + it('returns the max operator total across all bins', () => { + // op-1 totals per bin: [1, 3, 5, 0]; op-2 totals per bin: [0, 4, 0, 0]. + expect(computeWindowMax(makeBinned(OPERATORS), 'tasks')).toBe(5); + }); + + it('treats absent measures as zero', () => { + expect(computeWindowMax(makeBinned(OPERATORS), 'nope')).toBe(0); + }); + + it('is per-measure', () => { + expect(computeWindowMax(makeBinned(OPERATORS), 'bytes')).toBe(100); + }); + + it('restricts to the selected dimension keys', () => { + // filesystem only — op-1: [0, 0, 2, 0]; op-2: [0, 4, 0, 0]. + expect(computeWindowMax(makeBinned(OPERATORS), 'tasks', new Set(['filesystem']))).toBe(4); + // memory only — op-1: [1, 3, 3, 0]; op-2 has no memory data. + expect(computeWindowMax(makeBinned(OPERATORS), 'tasks', new Set(['memory']))).toBe(3); + }); + + it('treats a null/empty selection as all keys', () => { + expect(computeWindowMax(makeBinned(OPERATORS), 'tasks', null)).toBe(5); + expect(computeWindowMax(makeBinned(OPERATORS), 'tasks', new Set())).toBe(5); + }); +}); + +describe('extractDataFlowFrame', () => { + const binned = makeBinned(OPERATORS); + const stateNames = ['queueing', 'computing']; + + it('extracts totals, byState, byDimension and matrix at a bin', () => { + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 2, 5); + expect(frame.binIndex).toBe(2); + expect(frame.timeS).toBe(4); // bin start: 0 + 2 * 2s + expect(frame.measure).toBe('tasks'); + expect(frame.maxTotal).toBe(5); + + const op1 = frame.perOperator.get('op-1'); + expect(op1).toBeDefined(); + expect(op1!.total).toBe(5); + expect(op1!.byState).toEqual([0, 5]); // queueing 0, computing 3+2 + expect(op1!.byDimension).toEqual([3, 2]); // memory, filesystem + expect(op1!.matrix).toEqual([ + [0, 0], + [3, 2], + ]); + }); + + it('reads missing states/dimension keys as zero', () => { + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 1, 5); + const op2 = frame.perOperator.get('op-2'); + // op-2 only has queueing/filesystem — everything else is zero. + expect(op2!.matrix).toEqual([ + [0, 4], + [0, 0], + ]); + expect(op2!.byDimension).toEqual([0, 4]); + }); + + it('omits operators with an all-zero distribution at the bin', () => { + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 3, 5); + expect(frame.perOperator.size).toBe(0); + }); + + it('omits operators without the requested measure', () => { + const frame = extractDataFlowFrame(binned, stateNames, 'bytes', 2, 100); + expect(frame.perOperator.has('op-2')).toBe(false); + expect(frame.perOperator.get('op-1')!.total).toBe(100); + }); + + it('clamps the bin index into range', () => { + expect(extractDataFlowFrame(binned, stateNames, 'tasks', -3, 5).binIndex).toBe(0); + expect(extractDataFlowFrame(binned, stateNames, 'tasks', 99, 5).binIndex).toBe(NUM_BINS - 1); + }); + + it('exposes per-operator totals for EVERY declared measure at the bin', () => { + // Bin 2: op-1 tasks 3+2, bytes 100; op-2 all-zero (omitted entirely). + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 2, 5); + expect(frame.totalsByMeasure.get('op-1')).toEqual({ tasks: 5, bytes: 100 }); + expect(frame.totalsByMeasure.has('op-2')).toBe(false); + }); + + it('omits zero measures from the totals record', () => { + // Bin 1: op-1 bytes are zero — only tasks appears. + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 1, 5); + expect(frame.totalsByMeasure.get('op-1')).toEqual({ tasks: 3 }); + expect(frame.totalsByMeasure.get('op-2')).toEqual({ tasks: 4 }); + }); + + it('computes totalsByMeasure independently of the selected measure', () => { + // Selected measure "bytes" has no data at bin 1, so perOperator is empty, + // but the tasks totals are still exposed. + const frame = extractDataFlowFrame(binned, stateNames, 'bytes', 1, 100); + expect(frame.perOperator.size).toBe(0); + expect(frame.totalsByMeasure.get('op-1')).toEqual({ tasks: 3 }); + expect(frame.totalsByMeasure.get('op-2')).toEqual({ tasks: 4 }); + }); + + it('has an empty totalsByMeasure at an all-zero bin', () => { + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 3, 5); + expect(frame.totalsByMeasure.size).toBe(0); + }); + + it('accumulates global per-tier totals for every declared measure', () => { + // Bin 2 — tasks: memory 3 (op-1 computing), filesystem 2 (op-1 + // computing); bytes: memory 100, filesystem 0. Zero measures keep a + // zero-filled entry (decl-key-indexed). + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 2, 5); + expect(frame.dimensionTotalsByMeasure).toEqual({ + tasks: [3, 2], + bytes: [100, 0], + }); + }); + + it('sums global tier totals across operators', () => { + // Bin 1 — tasks memory: op-1 queueing 2 + computing 1; tasks + // filesystem: op-2 queueing 4. + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 1, 5); + expect(frame.dimensionTotalsByMeasure).toEqual({ + tasks: [3, 4], + bytes: [0, 0], + }); + }); + + it('keeps global tier totals unfiltered by the dimension selection', () => { + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 2, 3, { + selectedDimensions: new Set(['memory']), + }); + // Deselected filesystem keeps its true total — the legend shows dimmed + // tiers with their totals. + expect(frame.dimensionTotalsByMeasure).toEqual({ + tasks: [3, 2], + bytes: [100, 0], + }); + }); + + it('zero-fills global tier totals at an all-zero bin', () => { + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 3, 5); + expect(frame.dimensionTotalsByMeasure).toEqual({ + tasks: [0, 0], + bytes: [0, 0], + }); + }); + + it('aliases the label arrays to the bar arrays when no label measure is set', () => { + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 2, 5); + const op1 = frame.perOperator.get('op-1')!; + expect(frame.labelMeasure).toBe('tasks'); + expect(op1.labelByState).toBe(op1.byState); + expect(op1.labelByDimension).toBe(op1.byDimension); + }); + + it('computes label sums for an independent label measure, widths untouched', () => { + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 2, 5, { + labelMeasure: 'bytes', + }); + expect(frame.labelMeasure).toBe('bytes'); + const op1 = frame.perOperator.get('op-1')!; + // Bar-measure sums (segment widths) are unchanged... + expect(op1.byState).toEqual([0, 5]); + expect(op1.byDimension).toEqual([3, 2]); + // ...while labels reflect bytes: computing/memory 100 at bin 2. + expect(op1.labelByState).toEqual([0, 100]); + expect(op1.labelByDimension).toEqual([100, 0]); + // op-2 has no bytes data at all: label sums read as zero. + const op2 = frame.perOperator.get('op-2'); + expect(op2).toBeUndefined(); // all-zero tasks at bin 2 — omitted anyway + }); + + it('reads label sums as zero for operators without the label measure', () => { + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 1, 5, { + labelMeasure: 'bytes', + }); + const op2 = frame.perOperator.get('op-2')!; + expect(op2.byState).toEqual([4, 0]); + expect(op2.labelByState).toEqual([0, 0]); + expect(op2.labelByDimension).toEqual([0, 0]); + }); + + it('filters every per-operator value to the selected dimensions', () => { + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 2, 3, { + selectedDimensions: new Set(['memory']), + }); + const op1 = frame.perOperator.get('op-1')!; + // Unselected filesystem column reads as zero everywhere. + expect(op1.total).toBe(3); + expect(op1.byState).toEqual([0, 3]); + expect(op1.byDimension).toEqual([3, 0]); + expect(op1.matrix).toEqual([ + [0, 0], + [3, 0], + ]); + expect(frame.totalsByMeasure.get('op-1')).toEqual({ tasks: 3, bytes: 100 }); + // op-2 only has filesystem data — omitted entirely under this selection. + expect(frame.perOperator.has('op-2')).toBe(false); + expect(frame.totalsByMeasure.has('op-2')).toBe(false); + }); + + it('applies the dimension selection to label sums too', () => { + const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 2, 5, { + labelMeasure: 'bytes', + selectedDimensions: new Set(['filesystem']), + }); + const op1 = frame.perOperator.get('op-1')!; + // tasks/filesystem keeps op-1 visible, but bytes only exist in memory. + expect(op1.byState).toEqual([0, 2]); + expect(op1.labelByState).toEqual([0, 0]); + }); +}); + +describe('buildDataFlowMeta', () => { + it('builds decl-driven meta with per-measure window max', () => { + const meta = buildDataFlowMeta(makeBinned(OPERATORS), { Task: FSM_TYPE }, { unit: UNIT_SPEC }); + expect(meta.fsmType).toBe(FSM_TYPE); + expect(meta.stateNames).toEqual(['queueing', 'computing']); + expect(meta.bin.numBins).toBe(NUM_BINS); + expect(meta.windowMax).toEqual({ tasks: 5, bytes: 100 }); + expect([...meta.dimensionSelection]).toEqual(['memory', 'filesystem']); + expect(meta.quantitySpecs.unit).toBe(UNIT_SPEC); + }); + + it('tolerates a missing FSM declaration', () => { + const meta = buildDataFlowMeta(makeBinned(OPERATORS), {}, undefined); + expect(meta.fsmType).toBeNull(); + expect(meta.stateNames).toEqual(['computing', 'queueing']); + }); + + it('recomputes windowMax over the dimension selection', () => { + const meta = buildDataFlowMeta( + makeBinned(OPERATORS), + { Task: FSM_TYPE }, + { unit: UNIT_SPEC }, + new Set(['filesystem']) + ); + expect([...meta.dimensionSelection]).toEqual(['filesystem']); + // tasks/filesystem maxes at 4 (op-2, bin 1); bytes live only in memory. + expect(meta.windowMax).toEqual({ tasks: 4, bytes: 0 }); + }); +}); + +describe('resolveDataFlowMeasure', () => { + const decl = makeBinned().decl; + + it('keeps the selected measure when declared', () => { + expect(resolveDataFlowMeasure('bytes', decl)).toBe('bytes'); + }); + + it('falls back to the first declared measure when the selection is unknown', () => { + expect(resolveDataFlowMeasure('nope', decl)).toBe('tasks'); + expect(resolveDataFlowMeasure(null, decl)).toBe('tasks'); + }); + + it('honors the analyzer-declared default when nothing is selected', () => { + const withDefault = { ...decl, default_measure: 'bytes' }; + expect(resolveDataFlowMeasure(null, withDefault)).toBe('bytes'); + }); + + it('routes unknown selections through the declared default', () => { + const withDefault = { ...decl, default_measure: 'bytes' }; + expect(resolveDataFlowMeasure('nope', withDefault)).toBe('bytes'); + }); + + it('lets an explicit valid selection win over the declared default', () => { + const withDefault = { ...decl, default_measure: 'bytes' }; + expect(resolveDataFlowMeasure('tasks', withDefault)).toBe('tasks'); + }); + + it('ignores a default that does not name a declared measure', () => { + const withBadDefault = { ...decl, default_measure: 'nope' }; + expect(resolveDataFlowMeasure(null, withBadDefault)).toBe('tasks'); + }); + + it('returns null when no measures are declared', () => { + expect(resolveDataFlowMeasure(null, { ...decl, measures: [] })).toBeNull(); + expect( + resolveDataFlowMeasure(null, { ...decl, measures: [], default_measure: 'bytes' }) + ).toBeNull(); + }); +}); + +describe('resolveDataFlowLabelMeasure', () => { + const decl = makeBinned().decl; + + it('keeps the selected label measure when declared', () => { + expect(resolveDataFlowLabelMeasure('bytes', decl, 'tasks')).toBe('bytes'); + }); + + it('follows the bar measure for null or unknown selections', () => { + expect(resolveDataFlowLabelMeasure(null, decl, 'tasks')).toBe('tasks'); + expect(resolveDataFlowLabelMeasure('nope', decl, 'bytes')).toBe('bytes'); + }); +}); + +describe('formatDataFlowValue', () => { + const meta = buildDataFlowMeta(makeBinned(OPERATORS), { Task: FSM_TYPE }, { unit: UNIT_SPEC }); + + it('formats via the measure quantity spec with ~1 decimal', () => { + expect(formatDataFlowValue(2.5, 'tasks', meta)).toBe('2.5'); + }); + + it('falls back to a plain fixed-point value without a spec', () => { + // "bytes" quantity ("capacity_bytes") has no spec in this fixture. + expect(formatDataFlowValue(3, 'bytes', meta)).toBe('3.0'); + }); +}); + +describe('formatDataFlowValueCompact', () => { + const meta = buildDataFlowMeta( + makeBinned(OPERATORS), + { Task: FSM_TYPE }, + { unit: UNIT_SPEC, capacity_bytes: BYTES_SPEC } + ); + + it('keeps one decimal below 10 and drops trailing .0', () => { + expect(formatDataFlowValueCompact(3.2, 'tasks', meta)).toBe('3.2'); + expect(formatDataFlowValueCompact(2, 'tasks', meta)).toBe('2'); + }); + + it('rounds to integers from 10 up (2-3 significant digits)', () => { + expect(formatDataFlowValueCompact(45.3, 'tasks', meta)).toBe('45'); + expect(formatDataFlowValueCompact(482.4, 'tasks', meta)).toBe('482'); + }); + + it('uses the IEC prefix + symbol without a space for bytes', () => { + expect(formatDataFlowValueCompact(47185920, 'bytes', meta)).toBe('45MiB'); + expect(formatDataFlowValueCompact(1536, 'bytes', meta)).toBe('1.5KiB'); + expect(formatDataFlowValueCompact(100, 'bytes', meta)).toBe('100B'); + }); + + it('falls back to a plain compact number for unknown measures', () => { + expect(formatDataFlowValueCompact(7, 'nope', meta)).toBe('7'); + }); +}); + +describe('fitDataFlowSegmentLabel', () => { + const meta = buildDataFlowMeta( + makeBinned(OPERATORS), + { Task: FSM_TYPE }, + { unit: UNIT_SPEC, capacity_bytes: BYTES_SPEC } + ); + const TRACK = 168; + + it('returns the compact label when the segment is wide enough', () => { + // Full-width segment: 168px >= 1 char * 6px + 4px. + expect(fitDataFlowSegmentLabel(5, 5, 'tasks', meta, TRACK)).toBe('5'); + expect(fitDataFlowSegmentLabel(100, 100, 'bytes', meta, TRACK)).toBe('100B'); + }); + + it('hides the label when the segment is too narrow', () => { + // 1/1000 of the track = 0.168px — far below the 10px needed for "1". + expect(fitDataFlowSegmentLabel(1, 1000, 'tasks', meta, TRACK)).toBeNull(); + }); + + it('gates exactly at charPx * length + padding', () => { + // Label "5" needs 1 * 6 + 4 = 10px. Segment px = (5 / maxTotal) * 168. + expect(fitDataFlowSegmentLabel(5, 84, 'tasks', meta, TRACK)).toBe('5'); // 10px + expect(fitDataFlowSegmentLabel(5, 85, 'tasks', meta, TRACK)).toBeNull(); // ~9.88px + }); + + it('returns null for degenerate inputs', () => { + expect(fitDataFlowSegmentLabel(0, 5, 'tasks', meta, TRACK)).toBeNull(); + expect(fitDataFlowSegmentLabel(5, 0, 'tasks', meta, TRACK)).toBeNull(); + expect(fitDataFlowSegmentLabel(5, 5, 'tasks', meta, 0)).toBeNull(); + }); + + it('renders the label-measure text while the width stays on the bar measure', () => { + expect( + fitDataFlowSegmentLabel(5, 5, 'tasks', meta, TRACK, { value: 47185920, measure: 'bytes' }) + ).toBe('45MiB'); + }); + + it('width-gates using the label text against the bar-measure segment width', () => { + // Segment px = (5 / 42) * 168 = 20px: fits "5" (10px) but not the + // 5-char "45MiB" (34px) from the label measure. + expect(fitDataFlowSegmentLabel(5, 42, 'tasks', meta, TRACK)).toBe('5'); + expect( + fitDataFlowSegmentLabel(5, 42, 'tasks', meta, TRACK, { value: 47185920, measure: 'bytes' }) + ).toBeNull(); + }); + + it('hides the label when the label-measure value is zero', () => { + expect( + fitDataFlowSegmentLabel(5, 5, 'tasks', meta, TRACK, { value: 0, measure: 'bytes' }) + ).toBeNull(); + }); +}); diff --git a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts new file mode 100644 index 000000000..159c66e17 --- /dev/null +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts @@ -0,0 +1,520 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Pure helpers for the DAG data-flow overlay. All response parsing is isolated +// here so the rest of the UI works with normalized, pre-indexed structures. +// Everything is server-declared: state names/order come from the query +// bundle's FSM type declarations, dimension keys and measures from the +// response's `CategoricalDecl` — no hardcoded semantics. + +import type { + CategoricalDecl, + DataFlowTimelineBinned, + FsmTypeDecl, + QuantitySpec, + ZoomRange, +} from '@quent/utils'; +import { formatCompactWithPrefix, formatQuantity, formatQuantityCompact } from '@quent/utils'; + +/** Bin configuration of the current data-flow window (all values in seconds). */ +export interface DataFlowBinConfig { + startS: number; + endS: number; + binDurationS: number; + numBins: number; +} + +/** + * Presentation metadata for the data-flow overlay, derived once per response. + */ +export interface DataFlowMeta { + decl: CategoricalDecl; + /** + * FSM type declaration referenced by `decl.entity_type_name` (from the + * query bundle), when present. Drives state colors so they match the + * timeline view. + */ + fsmType: FsmTypeDecl | null; + /** + * Ordered state names: FSM declaration order, filtered to states present in + * the data. Falls back to sorted data keys when the declaration is missing. + */ + stateNames: string[]; + bin: DataFlowBinConfig; + /** + * Per-measure max operator total across ALL bins of the window — keeps the + * bar scale stable while scrubbing. Computed over + * {@link DataFlowMeta.dimensionSelection} only, so bar scaling stays honest + * when tiers are filtered out. + */ + windowMax: Record; + /** + * Effective dimension-key (tier) selection: the user's selection + * intersected with the declared keys, falling back to ALL declared keys + * when the selection is `null`, empty, or entirely stale. + */ + dimensionSelection: ReadonlySet; + /** Quantity specs (from the query bundle) keyed by quantity name. */ + quantitySpecs: { [key in string]?: QuantitySpec }; +} + +/** + * Per-operator distribution values at one bin. All values cover only the + * SELECTED dimension keys — unselected dimension columns read as zero. + */ +export interface DataFlowOperatorFrame { + /** Sum over all states and selected dimension keys. */ + total: number; + /** Totals indexed by `DataFlowMeta.stateNames` order. */ + byState: number[]; + /** Totals indexed by `decl.dimension_keys` order. */ + byDimension: number[]; + /** Values indexed `[stateIndex][dimensionIndex]`. */ + matrix: number[][]; + /** + * Per-state totals for {@link DataFlowFrame.labelMeasure} (same shape as + * `byState`; the SAME array instance when the label measure equals the bar + * measure). Drives in-segment labels while `byState` drives widths. + */ + labelByState: number[]; + /** + * Per-dimension totals for {@link DataFlowFrame.labelMeasure} (same shape + * as `byDimension`; the SAME array instance when the label measure equals + * the bar measure). + */ + labelByDimension: number[]; +} + +/** Snapshot of the data-flow distribution at the playhead's bin. */ +export interface DataFlowFrame { + binIndex: number; + /** Start time of the bin, in seconds relative to the query epoch. */ + timeS: number; + /** The measure this frame was extracted for (drives segment widths). */ + measure: string; + /** + * The measure driving in-segment value labels. Equals {@link measure} + * unless the user picked an independent label measure. + */ + labelMeasure: string; + /** Window max for the measure (see {@link DataFlowMeta.windowMax}). */ + maxTotal: number; + /** Operators with a non-zero total at this bin. */ + perOperator: Map; + /** + * Per-operator totals at this bin for EVERY declared measure (not just the + * selected one), summed over the SELECTED dimension keys only — drives the + * per-node totals label. Only measures with a non-zero total are present; + * operators that are zero across all measures are omitted entirely. + */ + totalsByMeasure: Map>; + /** + * GLOBAL per-tier totals at this bin for EVERY declared measure: the sum + * over ALL operators and states per dimension key, indexed by + * `decl.dimension_keys` order. Unlike the rest of the frame this ignores + * the tier selection — deselected tiers keep their true totals so the + * legend can annotate dimmed entries too. Every declared measure has an + * entry (possibly all zeros). + */ + dimensionTotalsByMeasure: Record; +} + +/** + * Normalize the response sentinel: the endpoint returns the binned timeline + * directly, and `null` (unsupported analyzer — HTTP 501) or `undefined` (not + * yet loaded) both collapse to `null`. + */ +export function normalizeDataFlowResponse( + response: DataFlowTimelineBinned | null | undefined +): DataFlowTimelineBinned | null { + return response ?? null; +} + +/** Whether the feature should be shown at all: supported and non-empty. */ +export function isDataFlowAvailable(response: DataFlowTimelineBinned | null | undefined): boolean { + const binned = normalizeDataFlowResponse(response); + return binned != null && Object.keys(binned.operators).length > 0; +} + +/** + * Resolve the request window: the zoom range when valid (end > start), + * otherwise the full query duration. + */ +export function resolveDataFlowWindow( + zoom: ZoomRange | null | undefined, + durationS: number +): { start: number; end: number } { + if (zoom && zoom.end > zoom.start) return { start: zoom.start, end: zoom.end }; + return { start: 0, end: durationS }; +} + +/** Map a time (seconds) to a bin index, clamped into `[0, numBins - 1]`. */ +export function timeToBinIndex(timeS: number, bin: DataFlowBinConfig): number { + if (bin.numBins <= 0 || !(bin.binDurationS > 0)) return 0; + const raw = Math.floor((timeS - bin.startS) / bin.binDurationS); + return Math.min(bin.numBins - 1, Math.max(0, raw)); +} + +/** Extract the bin configuration, converting `num_bins` (possibly bigint) to number. */ +export function extractBinConfig(binned: DataFlowTimelineBinned): DataFlowBinConfig { + const { span, bin_duration, num_bins } = binned.config; + return { + startS: span.start, + endS: span.end, + binDurationS: bin_duration, + numBins: Number(num_bins), + }; +} + +/** + * Ordered state names for display: FSM declaration order filtered to states + * present in the data; states missing from the declaration (or the whole + * declaration missing) fall back to sorted data-key order. + */ +export function resolveDataFlowStates( + binned: DataFlowTimelineBinned, + fsmType: FsmTypeDecl | null | undefined +): string[] { + const present = new Set(); + for (const series of Object.values(binned.operators)) { + for (const states of Object.values(series.values)) { + for (const state of Object.keys(states)) present.add(state); + } + } + const declared = fsmType?.states.map(s => s.name) ?? []; + const ordered = declared.filter(name => present.has(name)); + const orderedSet = new Set(ordered); + const extras = [...present].filter(s => !orderedSet.has(s)).sort(); + return [...ordered, ...extras]; +} + +/** + * Effective dimension-key (tier) selection against the declared keys. + * `null`, empty, and entirely-stale selections (no overlap with the declared + * keys, e.g. right after a query/engine switch) all resolve to ALL declared + * keys — "nothing selected" is never a valid rendering state. + */ +export function resolveDataFlowDimensions( + selected: ReadonlySet | null | undefined, + dimensionKeys: string[] +): ReadonlySet { + if (selected && selected.size > 0) { + const valid = dimensionKeys.filter(k => selected.has(k)); + if (valid.length > 0) return new Set(valid); + } + return new Set(dimensionKeys); +} + +/** + * Max operator total (summed over states and the SELECTED dimension keys) + * across all bins of the window for one measure. Missing entries count as + * zero. `selectedDimensions` follows {@link resolveDataFlowDimensions} + * semantics (`null`/empty/stale = all declared keys). + */ +export function computeWindowMax( + binned: DataFlowTimelineBinned, + measure: string, + selectedDimensions?: ReadonlySet | null +): number { + const numBins = Number(binned.config.num_bins); + const selected = resolveDataFlowDimensions( + selectedDimensions, + binned.decl.dimension_keys.map(k => k.key) + ); + let max = 0; + for (const series of Object.values(binned.operators)) { + const states = series.values[measure]; + if (!states) continue; + const totals = new Array(numBins).fill(0); + for (const dims of Object.values(states)) { + for (const [dimension, values] of Object.entries(dims)) { + if (!selected.has(dimension)) continue; + const len = Math.min(values.length, numBins); + for (let i = 0; i < len; i++) totals[i] += values[i]!; + } + } + for (const t of totals) { + if (t > max) max = t; + } + } + return max; +} + +/** Optional knobs for {@link extractDataFlowFrame}. */ +export interface ExtractDataFlowFrameOptions { + /** + * Measure driving in-segment labels (`labelByState`/`labelByDimension`). + * Defaults to the bar `measure` — the label arrays then alias + * `byState`/`byDimension`, adding zero cost per scrub tick. + */ + labelMeasure?: string; + /** + * Dimension keys (tiers) to include; follows + * {@link resolveDataFlowDimensions} semantics (`null`/empty/stale = all). + * Unselected dimension columns read as zero everywhere in the frame. + */ + selectedDimensions?: ReadonlySet | null; +} + +/** + * Extract the per-operator frame at `binIndex` for `measure`. Operators with + * an all-zero (or absent) distribution at the bin are omitted from + * `perOperator`. Missing states/dimension keys read as zero. + * + * Also computes {@link DataFlowFrame.totalsByMeasure} — per-operator totals + * for every declared measure at the bin — the global + * {@link DataFlowFrame.dimensionTotalsByMeasure} per-tier totals, and the + * per-state/per-dimension totals of the label measure (a single cheap pass, + * recomputed per scrub tick). + */ +export function extractDataFlowFrame( + binned: DataFlowTimelineBinned, + stateNames: string[], + measure: string, + binIndex: number, + maxTotal: number, + options: ExtractDataFlowFrameOptions = {} +): DataFlowFrame { + const labelMeasure = options.labelMeasure ?? measure; + const bin = extractBinConfig(binned); + const clamped = Math.min(Math.max(binIndex, 0), Math.max(bin.numBins - 1, 0)); + const dimensionKeys = binned.decl.dimension_keys.map(k => k.key); + const selected = resolveDataFlowDimensions(options.selectedDimensions, dimensionKeys); + const measureNames = binned.decl.measures.map(m => m.name); + const perOperator = new Map(); + const totalsByMeasure = new Map>(); + const dimensionTotalsByMeasure: Record = {}; + for (const measureName of measureNames) { + dimensionTotalsByMeasure[measureName] = dimensionKeys.map(() => 0); + } + + for (const [operatorId, series] of Object.entries(binned.operators)) { + // Totals at this bin for every declared measure (selected or not): + // per-operator sums over the selected dimension keys only, plus the + // global per-tier sums over ALL dimension keys (legend totals must + // survive tier deselection). + const totals: Record = {}; + let hasAnyMeasure = false; + for (const measureName of measureNames) { + const measureStates = series.values[measureName]; + if (!measureStates) continue; + const dimensionTotals = dimensionTotalsByMeasure[measureName]!; + let measureTotal = 0; + for (const state of stateNames) { + const dims = measureStates[state]; + if (!dims) continue; + for (let dimensionIndex = 0; dimensionIndex < dimensionKeys.length; dimensionIndex++) { + const dimension = dimensionKeys[dimensionIndex]!; + const value = dims[dimension]?.[clamped] ?? 0; + dimensionTotals[dimensionIndex]! += value; + if (selected.has(dimension)) measureTotal += value; + } + } + if (measureTotal > 0) { + totals[measureName] = measureTotal; + hasAnyMeasure = true; + } + } + if (hasAnyMeasure) totalsByMeasure.set(operatorId, totals); + + const states = series.values[measure]; + if (!states) continue; + const matrix = stateNames.map(() => dimensionKeys.map(() => 0)); + let total = 0; + stateNames.forEach((state, stateIndex) => { + const dims = states[state]; + if (!dims) return; + dimensionKeys.forEach((dimension, dimensionIndex) => { + if (!selected.has(dimension)) return; + const value = dims[dimension]?.[clamped] ?? 0; + matrix[stateIndex]![dimensionIndex] = value; + total += value; + }); + }); + if (total <= 0) continue; + const byState = matrix.map(row => row.reduce((acc, v) => acc + v, 0)); + const byDimension = dimensionKeys.map((_, dimensionIndex) => + matrix.reduce((acc, row) => acc + row[dimensionIndex]!, 0) + ); + + // Label-measure sums: alias the bar-measure arrays when the measures + // coincide, otherwise one extra states × dims pass (still trivial). + let labelByState = byState; + let labelByDimension = byDimension; + if (labelMeasure !== measure) { + labelByState = stateNames.map(() => 0); + labelByDimension = dimensionKeys.map(() => 0); + const labelStates = series.values[labelMeasure]; + if (labelStates) { + stateNames.forEach((state, stateIndex) => { + const dims = labelStates[state]; + if (!dims) return; + dimensionKeys.forEach((dimension, dimensionIndex) => { + if (!selected.has(dimension)) return; + const value = dims[dimension]?.[clamped] ?? 0; + labelByState[stateIndex]! += value; + labelByDimension[dimensionIndex]! += value; + }); + }); + } + } + + perOperator.set(operatorId, { + total, + byState, + byDimension, + matrix, + labelByState, + labelByDimension, + }); + } + + return { + binIndex: clamped, + timeS: bin.startS + clamped * bin.binDurationS, + measure, + labelMeasure, + maxTotal, + perOperator, + totalsByMeasure, + dimensionTotalsByMeasure, + }; +} + +/** + * Build the presentation metadata for one normalized response. + * `selectedDimensions` (the tier selection) shapes `windowMax` and is + * exposed resolved as `dimensionSelection` — the meta is rebuilt when the + * selection changes, which is rare (a user click), never per scrub tick. + */ +export function buildDataFlowMeta( + binned: DataFlowTimelineBinned, + fsmTypes: { [key in string]?: FsmTypeDecl } | undefined, + quantitySpecs: { [key in string]?: QuantitySpec } | undefined, + selectedDimensions?: ReadonlySet | null +): DataFlowMeta { + const fsmType = fsmTypes?.[binned.decl.entity_type_name] ?? null; + const dimensionSelection = resolveDataFlowDimensions( + selectedDimensions, + binned.decl.dimension_keys.map(k => k.key) + ); + const windowMax: Record = {}; + for (const measure of binned.decl.measures) { + windowMax[measure.name] = computeWindowMax(binned, measure.name, dimensionSelection); + } + return { + decl: binned.decl, + fsmType, + stateNames: resolveDataFlowStates(binned, fsmType), + bin: extractBinConfig(binned), + windowMax, + dimensionSelection, + quantitySpecs: quantitySpecs ?? {}, + }; +} + +/** + * Resolve the effective measure: the selected one when it is declared, + * otherwise the analyzer-declared `decl.default_measure` (when it names a + * declared measure), otherwise the first declared measure (or `null` when + * none exist). An explicit valid selection always wins over the default. + */ +export function resolveDataFlowMeasure( + selected: string | null, + decl: CategoricalDecl +): string | null { + const isDeclared = (name: string) => decl.measures.some(m => m.name === name); + if (selected != null && isDeclared(selected)) return selected; + if (decl.default_measure != null && isDeclared(decl.default_measure)) { + return decl.default_measure; + } + return decl.measures[0]?.name ?? null; +} + +/** + * Resolve the effective label measure: the selected one when it is declared, + * otherwise the bar measure (`null` selection = "follow the bar's measure"). + */ +export function resolveDataFlowLabelMeasure( + selected: string | null, + decl: CategoricalDecl, + barMeasure: string +): string { + if (selected != null && decl.measures.some(m => m.name === selected)) return selected; + return barMeasure; +} + +/** + * Format a data-flow value using the measure's declared quantity spec. + * Values are span-weighted per-bin averages, so fractional counts are + * expected — defaults to one decimal place. + */ +export function formatDataFlowValue( + value: number, + measureName: string, + meta: DataFlowMeta, + decimals: number = 1 +): string { + const measure = meta.decl.measures.find(m => m.name === measureName); + const spec = measure ? meta.quantitySpecs[measure.quantity] : undefined; + if (measure && spec) return formatQuantity(value, spec, measure.kind, decimals); + return value.toFixed(decimals); +} + +/** + * Compact form of {@link formatDataFlowValue} for tight spaces (in-segment + * labels, per-node totals): 2–3 significant digits, prefix + unit symbol + * only, no space — e.g. "482", "3.2", "1.2k", "45MiB". + */ +export function formatDataFlowValueCompact( + value: number, + measureName: string, + meta: DataFlowMeta +): string { + const measure = meta.decl.measures.find(m => m.name === measureName); + const spec = measure ? meta.quantitySpecs[measure.quantity] : undefined; + if (measure && spec) return formatQuantityCompact(value, spec, measure.kind); + return formatCompactWithPrefix(value, '', 'None'); +} + +/** + * Estimated pixels per character for in-bar labels (~8px font, tabular + * digits) — deliberately conservative so labels never overflow their segment. + */ +export const DATA_FLOW_LABEL_CHAR_PX = 6; +/** Horizontal breathing room required around an in-bar label, in pixels. */ +export const DATA_FLOW_LABEL_PAD_PX = 4; + +/** + * Width-gated label for one segment of the node flow bars. + * + * The bar's filled width is `total / maxTotal` of the track and each segment + * is flex-sized by `value / total`, so the segment's on-screen width is + * `(value / maxTotal) * trackPx` — computable purely from frame data, no DOM + * measurement. Returns the compact label when it fits at + * ~{@link DATA_FLOW_LABEL_CHAR_PX}px per character (plus + * {@link DATA_FLOW_LABEL_PAD_PX}px of padding), `null` when the segment is + * too narrow. + * + * When `label` is given, the rendered TEXT comes from `label.value` in + * `label.measure` (the independent label measure) while the segment WIDTH — + * and therefore the fit check's available space — stays on `value` in the + * bar's measure. A zero/absent label value yields `null` (no "0" clutter in + * segments that only have bar-measure data). + */ +export function fitDataFlowSegmentLabel( + value: number, + maxTotal: number, + measureName: string, + meta: DataFlowMeta, + trackPx: number, + label?: { value: number; measure: string } +): string | null { + const labelValue = label ? label.value : value; + const labelMeasure = label ? label.measure : measureName; + if (!(value > 0) || !(labelValue > 0) || !(maxTotal > 0) || !(trackPx > 0)) return null; + const segmentPx = (value / maxTotal) * trackPx; + const text = formatDataFlowValueCompact(labelValue, labelMeasure, meta); + const requiredPx = text.length * DATA_FLOW_LABEL_CHAR_PX + DATA_FLOW_LABEL_PAD_PX; + return segmentPx >= requiredPx ? text : null; +} diff --git a/ui/packages/@quent/hooks/src/dataFlow/dataFlowSelectors.ts b/ui/packages/@quent/hooks/src/dataFlow/dataFlowSelectors.ts new file mode 100644 index 000000000..12674c4cb --- /dev/null +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlowSelectors.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Selector hooks for data-flow atoms (HOOKS-02: no raw atom exports). + +import { useAtomValue, useSetAtom } from 'jotai'; +import { + dataFlowEnabledAtom, + playheadTimeSAtom, + selectedDataFlowMeasureAtom, + dataFlowLabelMeasureAtom, + dataFlowSelectedDimensionsAtom, + dataFlowMetaAtom, + dataFlowFrameAtom, +} from '../atoms/dataFlow'; + +export const useDataFlowEnabled = () => useAtomValue(dataFlowEnabledAtom); +export const useSetDataFlowEnabled = () => useSetAtom(dataFlowEnabledAtom); + +export const usePlayheadTimeS = () => useAtomValue(playheadTimeSAtom); +export const useSetPlayheadTimeS = () => useSetAtom(playheadTimeSAtom); + +export const useSelectedDataFlowMeasure = () => useAtomValue(selectedDataFlowMeasureAtom); +export const useSetSelectedDataFlowMeasure = () => useSetAtom(selectedDataFlowMeasureAtom); + +export const useDataFlowLabelMeasure = () => useAtomValue(dataFlowLabelMeasureAtom); +export const useSetDataFlowLabelMeasure = () => useSetAtom(dataFlowLabelMeasureAtom); + +export const useDataFlowSelectedDimensions = () => useAtomValue(dataFlowSelectedDimensionsAtom); +export const useSetDataFlowSelectedDimensions = () => useSetAtom(dataFlowSelectedDimensionsAtom); + +export const useDataFlowMeta = () => useAtomValue(dataFlowMetaAtom); +export const useDataFlowFrame = () => useAtomValue(dataFlowFrameAtom); diff --git a/ui/packages/@quent/hooks/src/dataFlow/useDataFlowSync.ts b/ui/packages/@quent/hooks/src/dataFlow/useDataFlowSync.ts new file mode 100644 index 000000000..d6a74fda2 Binary files /dev/null and b/ui/packages/@quent/hooks/src/dataFlow/useDataFlowSync.ts differ diff --git a/ui/packages/@quent/hooks/src/index.ts b/ui/packages/@quent/hooks/src/index.ts index 96e2d1cc8..8b826dbe2 100644 --- a/ui/packages/@quent/hooks/src/index.ts +++ b/ui/packages/@quent/hooks/src/index.ts @@ -93,6 +93,40 @@ export type { InspectedNodeData, } from './atoms/dagControls'; +// Data-flow overlay hooks (HOOKS-02: selector hooks over private atoms) +export { + useDataFlowEnabled, + useSetDataFlowEnabled, + usePlayheadTimeS, + useSetPlayheadTimeS, + useSelectedDataFlowMeasure, + useSetSelectedDataFlowMeasure, + useDataFlowLabelMeasure, + useSetDataFlowLabelMeasure, + useDataFlowSelectedDimensions, + useSetDataFlowSelectedDimensions, + useDataFlowMeta, + useDataFlowFrame, +} from './dataFlow/dataFlowSelectors'; +export { useDataFlowSync } from './dataFlow/useDataFlowSync'; +export { + normalizeDataFlowResponse, + isDataFlowAvailable, + resolveDataFlowWindow, + resolveDataFlowMeasure, + resolveDataFlowLabelMeasure, + resolveDataFlowDimensions, + formatDataFlowValue, + formatDataFlowValueCompact, + fitDataFlowSegmentLabel, +} from './dataFlow/dataFlow.utils'; +export type { + DataFlowBinConfig, + DataFlowMeta, + DataFlowFrame, + DataFlowOperatorFrame, +} from './dataFlow/dataFlow.utils'; + // Utility hooks export { useDeferredReady } from './dag/useDeferredReady'; diff --git a/ui/packages/@quent/utils/src/colors.test.ts b/ui/packages/@quent/utils/src/colors.test.ts index 8c4204f38..281561bb6 100644 --- a/ui/packages/@quent/utils/src/colors.test.ts +++ b/ui/packages/@quent/utils/src/colors.test.ts @@ -12,6 +12,7 @@ import { createCapacitiesColorFn, getColorByIndex, createFsmTypeColorFn, + createDataFlowStateColorFn, withOpacity, resetColorAssignments, darkenColor, @@ -467,3 +468,40 @@ describe('getLegendGradientStops', () => { expect(light[0]).not.toBe(dark[0]); }); }); + +describe('createDataFlowStateColorFn', () => { + const fsmType = { + name: 'batch', + states: [ + { name: 'batch_registered', usages: [] }, + { name: 'batch_queued', usages: [] }, + { name: 'batch_packaged', usages: [] }, + { name: 'batch_processing', usages: [] }, + { name: 'batch_consumed', usages: [] }, + ], + transitions: [], + } as never; + + it('gives appended synthetic states colors distinct from every declared state', () => { + const resolved = ['batch_queued', 'batch_packaged', 'batch_processing', 'task_working_space']; + const colorFn = createDataFlowStateColorFn(fsmType, resolved, 'light'); + const declaredColors = [ + 'batch_registered', + 'batch_queued', + 'batch_packaged', + 'batch_processing', + 'batch_consumed', + ].map(colorFn); + expect(declaredColors).not.toContain(colorFn('task_working_space')); + }); + + it('keeps declared states on their FSM declaration palette indices', () => { + const withSynthetic = createDataFlowStateColorFn( + fsmType, + ['batch_queued', 'task_working_space'], + 'light' + ); + const withoutSynthetic = createDataFlowStateColorFn(fsmType, ['batch_queued'], 'light'); + expect(withSynthetic('batch_queued')).toBe(withoutSynthetic('batch_queued')); + }); +}); diff --git a/ui/packages/@quent/utils/src/colors.ts b/ui/packages/@quent/utils/src/colors.ts index a491fe121..25e981d0c 100644 --- a/ui/packages/@quent/utils/src/colors.ts +++ b/ui/packages/@quent/utils/src/colors.ts @@ -204,6 +204,32 @@ export function createFsmTypeColorFn( }; } +/** + * State colors for the data-flow overlay: states declared in the FSM keep + * their declaration-index palette colors (consistent with the timeline + * lanes), while synthetic states the analyzer appends (e.g. a working-space + * series) continue the palette after the declared block — so they can never + * collide with a declared state's color. + */ +export function createDataFlowStateColorFn( + fsmType: FsmTypeDecl | null | undefined, + resolvedStates: readonly string[], + theme: PaletteTheme +): (stateName: string) => ChartColor { + const declared = new Map(); + fsmType?.states.forEach((state, index) => declared.set(state.name, index)); + const appended = new Map(); + for (const state of resolvedStates) { + if (!declared.has(state) && !appended.has(state)) { + appended.set(state, declared.size + appended.size); + } + } + return (stateName: string) => { + const index = declared.get(stateName) ?? appended.get(stateName); + return index != null ? getColorByIndex(index, theme) : getColorForKey(stateName, theme); + }; +} + /** * Build a deterministic state->index lookup from FSM declarations. * State index controls palette position so same state names stay consistent. diff --git a/ui/packages/@quent/utils/src/formatters.test.ts b/ui/packages/@quent/utils/src/formatters.test.ts index 7dd2a875b..eb4ddbe69 100644 --- a/ui/packages/@quent/utils/src/formatters.test.ts +++ b/ui/packages/@quent/utils/src/formatters.test.ts @@ -7,6 +7,8 @@ import { formatDurationForWindow, formatDurationForAxisInterval, formatWithPrefix, + formatCompactWithPrefix, + formatQuantityCompact, formatNumber, formatNumberWithMaxFractionDigits, formatBytes, @@ -463,6 +465,63 @@ describe('formatQuantity', () => { }); }); +// --------------------------------------------------------------------------- +// formatCompactWithPrefix / formatQuantityCompact +// --------------------------------------------------------------------------- + +describe('formatCompactWithPrefix', () => { + it('keeps one decimal below 10 and drops trailing .0', () => { + expect(formatCompactWithPrefix(3.2, '', 'None')).toBe('3.2'); + expect(formatCompactWithPrefix(2, '', 'None')).toBe('2'); + expect(formatCompactWithPrefix(0.4, '', 'None')).toBe('0.4'); + }); + + it('rounds to integers from 10 up (2-3 significant digits)', () => { + expect(formatCompactWithPrefix(45.3, '', 'None')).toBe('45'); + expect(formatCompactWithPrefix(482.4, '', 'None')).toBe('482'); + }); + + it('scales SI values without a space', () => { + expect(formatCompactWithPrefix(1234, '', 'Si')).toBe('1.2k'); + expect(formatCompactWithPrefix(45e6, '', 'Si')).toBe('45M'); + expect(formatCompactWithPrefix(0.02, 's', 'Si')).toBe('20ms'); + }); + + it('scales IEC values without a space', () => { + expect(formatCompactWithPrefix(1536, 'B', 'Iec')).toBe('1.5KiB'); + expect(formatCompactWithPrefix(47185920, 'B', 'Iec')).toBe('45MiB'); + expect(formatCompactWithPrefix(100, 'B', 'Iec')).toBe('100B'); + }); + + it('handles zero and negatives', () => { + expect(formatCompactWithPrefix(0, 'B', 'Iec')).toBe('0B'); + expect(formatCompactWithPrefix(0, '', 'None')).toBe('0'); + expect(formatCompactWithPrefix(-1234, '', 'Si')).toBe('-1.2k'); + }); + + it('keeps sub-prefix values unscaled for Iec', () => { + expect(formatCompactWithPrefix(0.5, 'B', 'Iec')).toBe('0.5B'); + }); +}); + +describe('formatQuantityCompact', () => { + const bytesSpec: QuantitySpec = { + symbol: 'B', + singular: 'byte', + plural: 'bytes', + occupancy_prefix: 'Iec', + rate_prefix: 'Si', + }; + + it('formats Occupancy via the occupancy prefix system', () => { + expect(formatQuantityCompact(47185920, bytesSpec, 'Occupancy')).toBe('45MiB'); + }); + + it('formats Rate via the rate prefix system with /s', () => { + expect(formatQuantityCompact(1500, bytesSpec, 'Rate')).toBe('1.5kB/s'); + }); +}); + // --------------------------------------------------------------------------- // Attribute value helpers // --------------------------------------------------------------------------- diff --git a/ui/packages/@quent/utils/src/formatters.ts b/ui/packages/@quent/utils/src/formatters.ts index 205c7179b..1e05c821d 100644 --- a/ui/packages/@quent/utils/src/formatters.ts +++ b/ui/packages/@quent/utils/src/formatters.ts @@ -150,6 +150,65 @@ export function formatWithPrefix( return `${sign}${(abs / last[0]).toFixed(decimals)} ${last[1]}${symbol}`; } +/** + * 2–3 significant digits: one decimal below 10, integers from 10 up. + * Trailing ".0" is dropped ("2", not "2.0"). + */ +function compactDigits(scaled: number): string { + const fixed = scaled >= 10 ? scaled.toFixed(0) : scaled.toFixed(1); + return fixed.endsWith('.0') ? fixed.slice(0, -2) : fixed; +} + +/** + * Compact variant of {@link formatWithPrefix} for tight spaces (in-bar labels): + * 2–3 significant digits, no space, prefix + symbol only — e.g. "482", "1.2k", + * "45MiB". + */ +export function formatCompactWithPrefix( + value: number, + symbol: string, + prefixSystem: PrefixSystem +): string { + const abs = value < 0 ? -value : value; + const sign = value < 0 ? '-' : ''; + if (value === 0) return `0${symbol}`; + + if (prefixSystem === 'Si' && abs < 1) { + for (let i = 1; i < SI_DOWN.length; i++) { + if (abs >= SI_DOWN[i][0]) { + return `${sign}${compactDigits(abs / SI_DOWN[i][0])}${SI_DOWN[i][1]}${symbol}`; + } + } + const last = SI_DOWN[SI_DOWN.length - 1]; + return `${sign}${compactDigits(abs / last[0])}${last[1]}${symbol}`; + } + + if (prefixSystem !== 'None') { + const table = prefixSystem === 'Iec' ? IEC : SI_UP; + for (let i = 0; i < table.length; i++) { + if (abs >= table[i][0]) { + return `${sign}${compactDigits(abs / table[i][0])}${table[i][1]}${symbol}`; + } + } + } + + return `${sign}${compactDigits(abs)}${symbol}`; +} + +/** + * Compact variant of {@link formatQuantity}: same prefix-system/kind + * resolution, but formatted via {@link formatCompactWithPrefix}. + */ +export function formatQuantityCompact( + value: number, + spec: QuantitySpec, + kind: CapacityKind +): string { + const prefixSystem = kind === 'Occupancy' ? spec.occupancy_prefix : spec.rate_prefix; + const symbol = kind === 'Rate' ? `${spec.symbol}/s` : spec.symbol; + return formatCompactWithPrefix(value, symbol, prefixSystem); +} + /** * Format a plain number with locale-appropriate grouping separators and sensible decimal places. * Integers are formatted with commas (e.g. 1,234,567). diff --git a/ui/packages/@quent/utils/src/index.ts b/ui/packages/@quent/utils/src/index.ts index 9a811afc2..32a2c0f06 100644 --- a/ui/packages/@quent/utils/src/index.ts +++ b/ui/packages/@quent/utils/src/index.ts @@ -24,6 +24,7 @@ export { isLightColor, createCapacitiesColorFn, createFsmTypeColorFn, + createDataFlowStateColorFn, CONTINUOUS_PALETTES, continuousColor, getLegendGradientStops, @@ -36,6 +37,8 @@ export { formatDurationForWindow, formatDurationForAxisInterval, formatQuantity, + formatQuantityCompact, + formatCompactWithPrefix, formatBytes, formatNumber, formatAttributeValue, diff --git a/ui/packages/@quent/utils/src/types/index.ts b/ui/packages/@quent/utils/src/types/index.ts index c2f46d225..882f52caa 100644 --- a/ui/packages/@quent/utils/src/types/index.ts +++ b/ui/packages/@quent/utils/src/types/index.ts @@ -8,6 +8,11 @@ export type { BulkTimelinesResponse } from '../../../../../../examples/simulator export type { BulkTimelinesResponseEntry } from '../../../../../../examples/simulator/server/ts-bindings/BulkTimelinesResponseEntry'; export type { CapacityDecl } from '../../../../../../examples/simulator/server/ts-bindings/CapacityDecl'; export type { CapacityKind } from '../../../../../../examples/simulator/server/ts-bindings/CapacityKind'; +export type { CategoricalDecl } from '../../../../../../examples/simulator/server/ts-bindings/CategoricalDecl'; +export type { CategoricalSeries } from '../../../../../../examples/simulator/server/ts-bindings/CategoricalSeries'; +export type { CategoricalTimelineRequest } from '../../../../../../examples/simulator/server/ts-bindings/CategoricalTimelineRequest'; +export type { DataFlowTimelineBinned } from '../../../../../../examples/simulator/server/ts-bindings/DataFlowTimelineBinned'; +export type { DimensionKeyDecl } from '../../../../../../examples/simulator/server/ts-bindings/DimensionKeyDecl'; export type { Edge } from '../../../../../../examples/simulator/server/ts-bindings/Edge'; export type { Engine } from '../../../../../../examples/simulator/server/ts-bindings/Engine'; export type { EngineImplementationAttributes } from '../../../../../../examples/simulator/server/ts-bindings/EngineImplementationAttributes'; diff --git a/ui/src/components/DataFlowOverlay.test.tsx b/ui/src/components/DataFlowOverlay.test.tsx new file mode 100644 index 000000000..279fed0bc --- /dev/null +++ b/ui/src/components/DataFlowOverlay.test.tsx @@ -0,0 +1,524 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { useEffect } from 'react'; +import { Provider } from 'jotai'; +import { ReactFlowProvider } from '@xyflow/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { + useDataFlowSync, + useSetDataFlowEnabled, + useSetDataFlowLabelMeasure, + useSetDataFlowSelectedDimensions, + useSetSelectedNodeData, +} from '@quent/hooks'; +import { + DagPlayhead, + DAGLegend, + DAGNodeInfoPanel, + NodeFlowBar, + registerAxisPointerSync, + unregisterAxisPointerSync, +} from '@quent/components'; +import type { DataFlowTimelineBinned, EntityRef, QueryBundle } from '@quent/utils'; + +// 4 bins of 2s over [0, 8): op-1 task totals per bin are [1, 3, 5, 0] and +// byte totals are [0, 1500000, 0, 0]. +const RESPONSE: DataFlowTimelineBinned = { + config: { span: { start: 0, end: 8 }, bin_duration: 2, num_bins: BigInt(4) }, + decl: { + entity_type_name: 'Task', + dimension_name: 'Data location', + dimension_keys: [ + { key: 'memory', display_name: 'Memory' }, + { key: 'filesystem', display_name: 'Filesystem' }, + ], + measures: [ + { name: 'tasks', display_name: 'Tasks', quantity: 'unit', kind: 'Occupancy' }, + { name: 'bytes', display_name: 'Bytes', quantity: 'capacity_bytes', kind: 'Occupancy' }, + ], + default_measure: null, + }, + operators: { + 'op-1': { + values: { + tasks: { + queueing: { memory: [1, 2, 0, 0] }, + computing: { memory: [0, 1, 3, 0], filesystem: [0, 0, 2, 0] }, + }, + bytes: { + computing: { memory: [0, 1500000, 0, 0] }, + }, + }, + }, + }, +}; + +// Same op-1 as RESPONSE plus a huge op-2: the window max (1000) squeezes +// op-1's segments below label width (1/1000 of the ~168px track). +const NARROW_RESPONSE: DataFlowTimelineBinned = { + ...RESPONSE, + operators: { + ...RESPONSE.operators, + 'op-2': { + values: { + tasks: { + queueing: { memory: [0, 0, 0, 1000] }, + }, + }, + }, + }, +}; + +// One dominant queueing segment at bin 0 (4/5 of the bar ≈ 134px) so a byte +// label ("1.4MiB", 40px) fits inside it — exercises the label-measure toggle. +const LABEL_RESPONSE: DataFlowTimelineBinned = { + ...RESPONSE, + operators: { + 'op-1': { + values: { + tasks: { + queueing: { memory: [4, 0, 0, 0] }, + computing: { memory: [1, 0, 0, 0] }, + }, + bytes: { + queueing: { memory: [1500000, 0, 0, 0] }, + }, + }, + }, + }, +}; + +const QUERY_BUNDLE = { + entities: { + fsm_types: { + Task: { + name: 'Task', + states: [ + { name: 'queueing', usages: [] }, + { name: 'computing', usages: [] }, + ], + transitions: [], + }, + }, + }, + quantity_specs: { + unit: { + symbol: '', + singular: 'task', + plural: 'tasks', + occupancy_prefix: 'None', + rate_prefix: 'None', + }, + capacity_bytes: { + symbol: 'B', + singular: 'byte', + plural: 'bytes', + occupancy_prefix: 'Iec', + rate_prefix: 'Si', + }, + }, +} as unknown as QueryBundle; + +interface HarnessProps { + /** Binned timeline; `null` = unsupported analyzer (HTTP 501 sentinel). */ + response: DataFlowTimelineBinned | null; + /** Whether the data-flow overlay is enabled (defaults to true). */ + enabled?: boolean; + /** In-segment label measure (null = follow the bar measure). */ + labelMeasure?: string | null; + /** Tier selection (null = all declared dimension keys). */ + selectedDimensions?: ReadonlySet | null; + children?: React.ReactNode; +} + +function Harness({ + response, + enabled = true, + labelMeasure = null, + selectedDimensions = null, + children, +}: HarnessProps) { + useDataFlowSync({ response, queryBundle: QUERY_BUNDLE }); + const setEnabled = useSetDataFlowEnabled(); + const setLabelMeasure = useSetDataFlowLabelMeasure(); + const setSelectedDimensions = useSetDataFlowSelectedDimensions(); + useEffect(() => { + setEnabled(enabled); + }, [enabled, setEnabled]); + useEffect(() => { + setLabelMeasure(labelMeasure); + }, [labelMeasure, setLabelMeasure]); + useEffect(() => { + setSelectedDimensions(selectedDimensions); + }, [selectedDimensions, setSelectedDimensions]); + return ( + children ?? ( + <> + + + + ) + ); +} + +function renderOverlay(props: DataFlowTimelineBinned | null | HarnessProps) { + const harnessProps: HarnessProps = + props !== null && 'response' in props ? props : { response: props }; + return render( + + + + ); +} + +function segmentLabels(): string[] { + return screen.queryAllByTestId('flow-segment-label').map(el => el.textContent ?? ''); +} + +function tierLabels(): string[] { + return screen.queryAllByTestId('flow-tier-label').map(el => el.textContent ?? ''); +} + +/** flex-grow values (segment width weights) of the state bar's segments. */ +function stateSegmentWidths(): string[] { + const bar = screen.getByTestId('node-flow-bar'); + const fill = (bar.children[0] as HTMLElement).children[0] as HTMLElement; + return [...fill.children].map(el => (el as HTMLElement).style.flexGrow); +} + +describe('data-flow overlay components', () => { + it('renders nothing for the null sentinel (unsupported analyzer — HTTP 501)', () => { + renderOverlay(null); + expect(screen.queryByTestId('dag-playhead')).not.toBeInTheDocument(); + expect(screen.queryByTestId('node-flow-bar')).not.toBeInTheDocument(); + }); + + it('renders the playhead slider initialized to the window start', () => { + renderOverlay(RESPONSE); + const slider = screen.getByRole('slider'); + expect(slider).toHaveAttribute('aria-valuemin', '0'); + expect(slider).toHaveAttribute('aria-valuemax', '8'); + expect(slider).toHaveAttribute('aria-valuenow', '0'); + }); + + it('shows totals for every measure with data at the current bin', () => { + renderOverlay(RESPONSE); + // Bin 0: tasks 1, bytes 0 — the zero measure is omitted. + expect(screen.getByTestId('flow-bar-totals').textContent).toBe('1'); + }); + + it('shows in-segment labels when segments are wide enough', () => { + renderOverlay(RESPONSE); + // Bin 0: single queueing segment, 1/5 of ~168px = ~34px — fits "1". + expect(segmentLabels()).toEqual(['1']); + }); + + it('advances one bin per ArrowRight and joins both measure totals', () => { + renderOverlay(RESPONSE); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + expect(slider).toHaveAttribute('aria-valuenow', '2'); + // Bin 1: tasks queueing 2 + computing 1 = 3; bytes 1500000 -> "1.4MiB". + expect(screen.getByTestId('flow-bar-totals').textContent).toBe('3 | 1.4MiB'); + // Both segments are wide enough (2/5 and 1/5 of ~168px). + expect(segmentLabels()).toEqual(['2', '1']); + }); + + it('hides in-segment labels when segments are too narrow', () => { + renderOverlay(NARROW_RESPONSE); + // Bin 0: op-1 total is 1 against a window max of 1000 — the segment is + // a fraction of a pixel, so no label fits, but the totals line remains. + expect(segmentLabels()).toEqual([]); + expect(screen.getByTestId('flow-bar-totals').textContent).toBe('1'); + }); + + it('jumps to the window end on End and keeps constant height with no data', () => { + renderOverlay(RESPONSE); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'End' }); + expect(slider).toHaveAttribute('aria-valuenow', '8'); + // Last bin is all-zero for op-1: labels collapse to a non-breaking space. + const bar = screen.getByTestId('node-flow-bar'); + expect(segmentLabels()).toEqual([]); + expect(tierLabels()).toEqual([]); + expect(screen.getByTestId('flow-bar-totals').textContent).toBe('\u00A0'); + expect(bar).toBeInTheDocument(); + }); + + it('renders both bars at the same labeled height (constant node height)', () => { + renderOverlay(RESPONSE); + const bar = screen.getByTestId('node-flow-bar'); + const stateTrack = bar.children[0] as HTMLElement; + const tierTrack = bar.children[1] as HTMLElement; + expect(stateTrack.className).toContain('h-[12px]'); + expect(tierTrack.className).toContain('h-[12px]'); + expect(tierTrack.className).toContain('mt-[2px]'); + }); +}); + +describe('playback while the overlay is disabled', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('stops the play interval and hides the synced pointer when disabled', () => { + vi.useFakeTimers(); + // Fake timeline chart: receives the showTip/hideTip actions that + // broadcastSyncedPointer/hideSyncedPointer dispatch to registered charts. + const dispatchAction = vi.fn(); + const fakeChart = { + convertToPixel: () => 42, + getHeight: () => 100, + dispatchAction, + getZr: () => ({ on: vi.fn(), off: vi.fn() }), + } as unknown as Parameters[0]; + registerAxisPointerSync(fakeChart); + try { + const { rerender } = renderOverlay(RESPONSE); + fireEvent.click(screen.getByRole('button', { name: 'Play data flow' })); + act(() => { + vi.advanceTimersByTime(100); + }); + // One tick advanced one bin (2s) and broadcast the synced crosshair. + expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '2'); + expect(dispatchAction).toHaveBeenCalledWith(expect.objectContaining({ type: 'showTip' })); + dispatchAction.mockClear(); + + // Disable the overlay mid-playback: the component renders null but + // stays mounted, so the interval must stop and the crosshair hide. + rerender( + + + + ); + expect(screen.queryByTestId('dag-playhead')).not.toBeInTheDocument(); + expect(dispatchAction).toHaveBeenCalledWith({ type: 'hideTip' }); + dispatchAction.mockClear(); + + // No further ticks: nothing is broadcast while disabled... + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(dispatchAction).not.toHaveBeenCalled(); + + // ...and re-enabling shows a paused playhead that did not advance. + rerender( + + + + ); + expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '2'); + expect(screen.getByRole('button', { name: 'Play data flow' })).toBeInTheDocument(); + } finally { + unregisterAxisPointerSync(fakeChart); + } + }); +}); + +describe('analyzer-declared default measure', () => { + // Same data as RESPONSE, but the analyzer declares bytes as the default. + const BYTES_DEFAULT_RESPONSE: DataFlowTimelineBinned = { + ...RESPONSE, + decl: { ...RESPONSE.decl, default_measure: 'bytes' }, + }; + + it('starts the flow bars on the declared default measure', () => { + renderOverlay(BYTES_DEFAULT_RESPONSE); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + // Bin 1 under bytes: one full-width 1500000-byte segment. Under tasks + // (the first declared measure) the labels would read ['2', '1']. + expect(segmentLabels()).toEqual(['1.4MiB']); + }); +}); + +describe('segment-label measure toggle', () => { + it('switches in-segment texts to the label measure without changing widths', () => { + const { rerender } = renderOverlay(LABEL_RESPONSE); + // Bin 0, labels follow the bar measure (tasks): queueing 4, computing 1. + expect(segmentLabels()).toEqual(['4', '1']); + const widthsBefore = stateSegmentWidths(); + expect(widthsBefore).toEqual(['4', '1']); + + rerender( + + + + ); + // Texts now come from bytes: queueing 1500000 -> "1.4MiB"; computing has + // zero bytes, so its label disappears instead of showing a stray "0". + expect(segmentLabels()).toEqual(['1.4MiB']); + // Segment widths still follow the bar measure (tasks). + expect(stateSegmentWidths()).toEqual(widthsBefore); + }); + + it('labels the tier bar with the label measure too', () => { + renderOverlay({ response: LABEL_RESPONSE, labelMeasure: 'bytes' }); + // Single memory tier holding all 1500000 bytes at bin 0. + expect(tierLabels()).toEqual(['1.4MiB']); + }); +}); + +describe('tier bar labels', () => { + it('shows width-gated per-tier totals inside the tier bar', () => { + renderOverlay(RESPONSE); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + // Bin 2: memory 3 (~101px) and filesystem 2 (~67px) both fit. + expect(tierLabels()).toEqual(['3', '2']); + }); + + it('hides tier labels when segments are too narrow', () => { + renderOverlay(NARROW_RESPONSE); + // op-1's bar is 1/1000 of the track \u2014 nothing fits in either bar. + expect(tierLabels()).toEqual([]); + expect(segmentLabels()).toEqual([]); + }); +}); + +describe('tier (dimension) selection', () => { + it('recomputes widths, labels, totals and windowMax over the selection', () => { + const { rerender } = renderOverlay(NARROW_RESPONSE); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + // Bin 2, all tiers: op-2's 1000 (memory, bin 3) dominates the window + // max, so op-1's total of 5 is sub-pixel \u2014 no labels anywhere. + expect(screen.getByTestId('flow-bar-totals').textContent).toBe('5'); + expect(segmentLabels()).toEqual([]); + expect(tierLabels()).toEqual([]); + + rerender( + + + + ); + // Filesystem only: op-2 vanishes from the window max (its data lives in + // memory), which becomes op-1's filesystem peak of 2 \u2014 the bar now fills + // the whole track, so the labels fit again (they could not at 1/1000). + expect(screen.getByTestId('flow-bar-totals').textContent).toBe('2'); + expect(segmentLabels()).toEqual(['2']); + expect(tierLabels()).toEqual(['2']); + }); + + it('treats an all-unknown (stale) selection as all tiers', () => { + renderOverlay({ response: RESPONSE, selectedDimensions: new Set(['GPU-0', 'GPU-1']) }); + // Bin 0 renders exactly like the unfiltered response. + expect(segmentLabels()).toEqual(['1']); + expect(screen.getByTestId('flow-bar-totals').textContent).toBe('1'); + }); +}); + +describe('DAGNodeInfoPanel matrix under tier selection', () => { + function renderPanel(selectedDimensions: ReadonlySet | null) { + function SelectNode() { + const setSelectedNodeData = useSetSelectedNodeData(); + useEffect(() => { + setSelectedNodeData({ + nodeId: 'op-1', + label: 'Op 1', + operationType: 'scan', + statistics: [], + }); + }, [setSelectedNodeData]); + return ; + } + return render( + + + + + + ); + } + + it('shows all dimension columns when every tier is selected', () => { + renderPanel(null); + expect(screen.getByText('Memory')).toBeInTheDocument(); + expect(screen.getByText('Filesystem')).toBeInTheDocument(); + }); + + it('hides deselected dimension columns', () => { + renderPanel(new Set(['memory'])); + expect(screen.getByText('Memory')).toBeInTheDocument(); + expect(screen.queryByText('Filesystem')).not.toBeInTheDocument(); + }); +}); + +describe('DAGLegend under tier selection', () => { + function renderLegend( + selectedDimensions: ReadonlySet | null, + response: DataFlowTimelineBinned = RESPONSE + ) { + return render( + + + + + + + + + ); + } + + /** Text of the "· " suffix of one tier entry, `null` when absent. */ + function tierTotal(label: string): string | null { + const entry = screen.getByText(label).parentElement!; + return entry.querySelector('[data-testid="legend-entry-total"]')?.textContent ?? null; + } + + it('lists every declared tier undimmed when all are selected', () => { + renderLegend(null); + expect(screen.getByText('Memory').closest('[data-dimmed]')).toBeNull(); + expect(screen.getByText('Filesystem').closest('[data-dimmed]')).toBeNull(); + }); + + it('greys out deselected tiers instead of dropping them', () => { + renderLegend(new Set(['memory'])); + expect(screen.getByText('Memory').closest('[data-dimmed]')).toBeNull(); + expect(screen.getByText('Filesystem').closest('[data-dimmed]')).not.toBeNull(); + }); + + it('appends each tier total at the current bin; zero totals get no suffix', () => { + renderLegend(null); + // Bin 0 (tasks): memory 1, filesystem 0. + expect(tierTotal('Memory')).toBe('· 1'); + expect(tierTotal('Filesystem')).toBeNull(); + }); + + it('updates the tier totals when the playhead crosses bins', () => { + renderLegend(null); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + // Bin 2 (tasks): memory 3, filesystem 2. + expect(tierTotal('Memory')).toBe('· 3'); + expect(tierTotal('Filesystem')).toBe('· 2'); + }); + + it('keeps totals on dimmed (deselected) tiers', () => { + renderLegend(new Set(['memory'])); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + // Filesystem is filtered out of the bars but keeps its global total. + expect(screen.getByText('Filesystem').closest('[data-dimmed]')).not.toBeNull(); + expect(tierTotal('Filesystem')).toBe('· 2'); + }); + + it('formats totals in the current flow measure via its quantity spec', () => { + renderLegend(null, { + ...RESPONSE, + decl: { ...RESPONSE.decl, default_measure: 'bytes' }, + }); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + // Bin 1 under the bytes measure: memory holds 1500000 bytes. + expect(tierTotal('Memory')).toBe('· 1.4MiB'); + expect(tierTotal('Filesystem')).toBeNull(); + }); +}); diff --git a/ui/src/components/QueryPlan.tsx b/ui/src/components/QueryPlan.tsx index d6a334304..11d0be1a4 100644 --- a/ui/src/components/QueryPlan.tsx +++ b/ui/src/components/QueryPlan.tsx @@ -2,13 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { useEffect, lazy, Suspense } from 'react'; -import { useQueryBundle } from '@quent/client'; +import { useQueryBundle, useDataFlow } from '@quent/client'; import { useQueryPlanVisualization } from '@/hooks/useQueryPlanVisualization'; import { TreeView } from '@quent/components'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@quent/components'; import { thinScrollbarClass, type QueryPlanDataItem } from '@quent/components'; import { useSelectedPlanId, useSetSelectedPlanId, useSetHoveredWorkerId } from '@quent/hooks'; -import { DAGControls, DAGNodeInfoPanel } from '@quent/components'; +import { DAGControls, DAGNodeInfoPanel, DagPlayhead } from '@quent/components'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@quent/components'; import { useDagNodeColoring, @@ -16,7 +16,11 @@ import { useDagEdgeColoring, useOperatorStatFields, usePortStatFields, + useDataFlowSync, + useDebouncedZoomRange, + resolveDataFlowWindow, } from '@quent/hooks'; +import { MAX_TIMELINE_BINS } from '@quent/utils'; import { computeNodeColoring, computeEdgeWidthConfig, @@ -49,6 +53,27 @@ export function QueryPlan({ queryId, engineId }: { queryId: string; engineId: st const { dagData, treeData, error: dagError } = useQueryPlanVisualization(queryBundle, planId); + // Data-flow overlay: fetch the categorical timeline for the current zoom + // window (fallback: full query duration) and sync it into the data-flow + // atoms. The first response doubles as the feature probe — `null` (HTTP + // 501, analyzer without data-flow support) or an empty result hides the + // playhead, bars, controls, and legend entries. + const debouncedZoomRange = useDebouncedZoomRange(); + const dataFlowWindow = resolveDataFlowWindow(debouncedZoomRange, queryBundle?.duration_s ?? 0); + const { data: dataFlowResponse } = useDataFlow( + { + engineId, + queryId, + config: { + num_bins: MAX_TIMELINE_BINS, + start: dataFlowWindow.start, + end: dataFlowWindow.end, + }, + }, + { enabled: !!queryBundle && dataFlowWindow.end > dataFlowWindow.start } + ); + useDataFlowSync({ response: dataFlowResponse, queryBundle }); + useDagNodeColoring(dagData.nodes, computeNodeColoring, isDark); useDagEdgeWidthConfig(dagData.edges, computeEdgeWidthConfig); useDagEdgeColoring(dagData.edges, computeEdgeColoring, isDark); @@ -188,7 +213,8 @@ export function QueryPlan({ queryId, engineId }: { queryId: string; engineId: st
- + +
diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index 3bd63541c..69e0f7ff2 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -28,6 +28,12 @@ export default defineConfig({ }, }, resolve: { + // Mirror vite.config.ts: the workspace packages each resolve their own + // copy of these (pnpm peer-hash duplicates). Without dedupe, a jotai + // in a test does NOT scope atoms used inside @quent/hooks — + // they silently fall back to jotai's global default store and state + // leaks between tests. + dedupe: ['react', 'react-dom', 'jotai', '@tanstack/react-query', '@tanstack/react-router'], alias: { '@': path.resolve(__dirname, './src'), },