From fedc7cdc1b3edf8092e7b20f9a02a00e24e314c1 Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Tue, 14 Jul 2026 14:43:43 -0500 Subject: [PATCH 01/14] feat(analyzer,server): generic data-flow distribution timeline protocol Add a generic protocol for the DAG data-flow-over-time view: per operator of a query, a binned timeline of a distribution over (FSM state x downstream-declared dimension) for downstream-declared measures. - quent-analyzer: DistributionTimelineBuilder, span-weighted aggregation over opaque (series, measure, state, dimension) keys - quent-ui: DistributionTimelineRequest/Decl/Series DTOs (ts-rs exported) - quent-query-engine-ui: DataFlowTimelineResponse (Unsupported | Binned) - quent-query-engine-analyzer: UiAnalyzer::data_flow_timeline with a default Unsupported impl so existing analyzers keep compiling - quent-query-engine-server: POST /api/engines/{id}/timeline/data-flow - simulator: reference implementation (dimension = memory resource instance the state uses, measures = tasks/bytes) + functional tests against the fixed scenario Co-Authored-By: Claude Fable 5 --- .../src/timeline/binned/distribution.rs | 194 +++++++++++++++++ crates/analyzer/src/timeline/binned/mod.rs | 1 + crates/ui/src/timeline/distribution.rs | 80 +++++++ crates/ui/src/timeline/mod.rs | 1 + docs/domains/query_engine/README.md | 25 +++ domains/query_engine/analyzer/src/ui.rs | 15 ++ domains/query_engine/server/src/ui.rs | 35 +++ .../tests/fixed/tests/data_flow.rs | 180 ++++++++++++++++ domains/query_engine/ui/src/data_flow.rs | 36 ++++ domains/query_engine/ui/src/lib.rs | 2 + examples/simulator/analyzer/src/lib.rs | 200 +++++++++++++++++- examples/simulator/server/build.rs | 4 + .../ts-bindings/DataFlowTimelineBinned.ts | 25 +++ .../ts-bindings/DataFlowTimelineResponse.ts | 7 + .../server/ts-bindings/DimensionKeyDecl.ts | 14 ++ .../server/ts-bindings/DistributionDecl.ts | 31 +++ .../server/ts-bindings/DistributionSeries.ts | 9 + .../DistributionTimelineRequest.ts | 19 ++ .../server/ts-bindings/MeasureDecl.ts | 24 +++ 19 files changed, 895 insertions(+), 7 deletions(-) create mode 100644 crates/analyzer/src/timeline/binned/distribution.rs create mode 100644 crates/ui/src/timeline/distribution.rs create mode 100644 domains/query_engine/tests/fixed/tests/data_flow.rs create mode 100644 domains/query_engine/ui/src/data_flow.rs create mode 100644 examples/simulator/server/ts-bindings/DataFlowTimelineBinned.ts create mode 100644 examples/simulator/server/ts-bindings/DataFlowTimelineResponse.ts create mode 100644 examples/simulator/server/ts-bindings/DimensionKeyDecl.ts create mode 100644 examples/simulator/server/ts-bindings/DistributionDecl.ts create mode 100644 examples/simulator/server/ts-bindings/DistributionSeries.ts create mode 100644 examples/simulator/server/ts-bindings/DistributionTimelineRequest.ts create mode 100644 examples/simulator/server/ts-bindings/MeasureDecl.ts diff --git a/crates/analyzer/src/timeline/binned/distribution.rs b/crates/analyzer/src/timeline/binned/distribution.rs new file mode 100644 index 000000000..6e428e428 --- /dev/null +++ b/crates/analyzer/src/timeline/binned/distribution.rs @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Binned timelines of weighted distributions over (state, dimension) pairs. +//! +//! A distribution timeline describes, per opaque series (e.g. an operator in a +//! query engine), how some weighted quantity (a "measure", e.g. an entity +//! count) is distributed 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. +//! +//! This module is application-agnostic: series, measures, states, and +//! dimension keys are all 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 distribution timeline. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct DistributionKey<'a, S> { + /// 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: &'a str, + /// The FSM state name during the span. + pub state: &'a str, + /// Application-defined dimension key (opaque to the aggregation). + pub dimension: &'a str, +} + +/// A binned timeline of weighted (state, dimension) distributions for +/// multiple series and measures. +#[derive(Clone, Debug)] +pub struct DistributionTimeline<'a, S> { + pub config: BinnedSpan, + pub data: HashMap, Vec>, +} + +/// Builds a [`DistributionTimeline`] 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 DistributionTimelineBuilder<'a, S> { + aggregator: KeyedAggregator>, +} + +impl<'a, S> DistributionTimelineBuilder<'a, S> +where + S: Eq + Hash + Clone, +{ + 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: DistributionKey<'a, S>, + span: SpanNanoSec, + weight: f64, + ) -> AnalyzerResult<()> { + self.aggregator.try_push(span, (key, weight)) + } + + pub fn build(self) -> DistributionTimeline<'a, S> { + DistributionTimeline { + 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, + ) -> DistributionKey<'a, u32> { + DistributionKey { + series, + measure, + state, + dimension, + } + } + + #[test] + fn span_weighting_across_bin_boundaries() -> AnalyzerResult<()> { + let mut builder = DistributionTimelineBuilder::new(test_config()); + + // Spans [0, 300) and [250, 450) of weight 1 each. + builder.try_push(key(1, "count", "a", "x"), SpanNanoSec::try_new(0, 300).unwrap(), 1.0)?; + builder.try_push( + key(1, "count", "a", "x"), + SpanNanoSec::try_new(250, 450).unwrap(), + 1.0, + )?; + + let timeline = builder.build(); + let bins = timeline.data.get(&key(1, "count", "a", "x")).unwrap(); + assert_eq!( + bins[..], + [1.0, 1.0, 1.5, 1.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0] + ); + Ok(()) + } + + #[test] + fn distinct_series_measures_states_dimensions() -> AnalyzerResult<()> { + let mut builder = DistributionTimelineBuilder::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(()) + } + + #[test] + fn zero_duration_span_is_noop() -> AnalyzerResult<()> { + let mut builder = DistributionTimelineBuilder::new(test_config()); + builder.try_push( + key(1, "count", "a", "x"), + SpanNanoSec::try_new(500, 500).unwrap(), + 1.0, + )?; + + let timeline = builder.build(); + // The key exists (aggregator was created) but all bins remain zero. + let bins = timeline.data.get(&key(1, "count", "a", "x")).unwrap(); + assert_eq!(bins[..], [0.0; 10]); + Ok(()) + } + + #[test] + fn out_of_window_span_contributes_nothing() -> AnalyzerResult<()> { + let mut builder = DistributionTimelineBuilder::new(test_config()); + builder.try_push( + key(1, "count", "a", "x"), + SpanNanoSec::try_new(2000, 3000).unwrap(), + 1.0, + )?; + + let timeline = builder.build(); + let bins = timeline.data.get(&key(1, "count", "a", "x")).unwrap(); + assert_eq!(bins[..], [0.0; 10]); + Ok(()) + } +} diff --git a/crates/analyzer/src/timeline/binned/mod.rs b/crates/analyzer/src/timeline/binned/mod.rs index 56110fe94..8820f7220 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 distribution; pub mod resource; /// A trait for types that can aggregate items into a sequence of time bins. diff --git a/crates/ui/src/timeline/distribution.rs b/crates/ui/src/timeline/distribution.rs new file mode 100644 index 000000000..68332d994 --- /dev/null +++ b/crates/ui/src/timeline/distribution.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Requests and responses for distribution timelines: binned timelines of a +//! weighted distribution over (FSM state, application-defined dimension) +//! pairs, for one or more application-declared measures. +//! +//! 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 distribution timeline. +#[derive(TS, Debug, Clone, Serialize, Deserialize)] +pub struct DistributionTimelineRequest { + /// 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 distribution timeline, +/// e.g. an entity count or a number of bytes. +#[derive(TS, Debug, Clone, Serialize)] +pub struct MeasureDecl { + /// Unique name; key into [`DistributionSeries::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 distribution timeline. +#[derive(TS, Debug, Clone, Serialize)] +pub struct DimensionKeyDecl { + /// The key used in [`DistributionSeries::values`]. + pub key: String, + /// Human-friendly display name. + pub display_name: String, +} + +/// Presentation metadata for a distribution 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 DistributionDecl { + /// The FSM type whose states are distributed. 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, +} + +/// Binned values of one distribution 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 DistributionSeries { + pub values: HashMap>>>, +} diff --git a/crates/ui/src/timeline/mod.rs b/crates/ui/src/timeline/mod.rs index 9d67199fa..f1b6fbbcd 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 distribution; pub mod request; pub mod response; diff --git a/docs/domains/query_engine/README.md b/docs/domains/query_engine/README.md index 6f4cc4684..f97026d60 100644 --- a/docs/domains/query_engine/README.md +++ b/docs/domains/query_engine/README.md @@ -189,6 +189,31 @@ act as [Resource Groups][resource-group], forming a hierarchy through which resource usages can be aggregated. See [Resource Group][resource-group] for details. +## Data-flow distribution timeline + +The analyzer trait offers an optional `data_flow_timeline` method (HTTP: +`POST /api/engines/{engine_id}/timeline/data-flow`) powering the UI's +data-flow-over-time view of a query plan: for every [Operator][operator] of a +query, a binned timeline of a distribution over +(FSM state × application-defined dimension), for one or more +application-declared measures. + +Consistent with Operators having no FSM (see [Operator][operator] notes), all +semantics live in the application's analyzer: + +- **Entity**: which FSM type is distributed (e.g. a task or batch entity that + works on behalf of an Operator), referenced by `entity_type_name` into the + query bundle's FSM type declarations for state names and ordering. +- **Dimension**: an opaque, small, enumerable key set declared per response + (e.g. where an entity's data resides), with display names and stable order. +- **Measures**: named weights (e.g. an entity count, resident bytes) with a + quantity spec reference for unit formatting. + +Bin values are span-weighted (an entity in a state for a fraction of a bin +contributes that fraction), matching all other timelines. Analyzers that do +not provide the feature return `Unsupported` — the default implementation — +and the UI hides the view. + [mutual-exclusion]: ../../modeling/README.md#mutual-exclusion [engine]: #engine [entity]: ../../modeling/entity.md diff --git a/domains/query_engine/analyzer/src/ui.rs b/domains/query_engine/analyzer/src/ui.rs index 0e28316bd..fb89fb0e0 100644 --- a/domains/query_engine/analyzer/src/ui.rs +++ b/domains/query_engine/analyzer/src/ui.rs @@ -11,6 +11,7 @@ use quent_query_engine_ui as ui; use quent_ui::{ entities::{request::EntityListRequest, response::EntityListResponse}, timeline::{ + distribution::DistributionTimelineRequest, request::{BulkChunkedTimelineRequest, BulkTimelineRequest, SingleTimelineRequest}, response::{ BulkChunkedTimelinesResponse, BulkTimelinesResponse, BulkTimelinesResponseEntry, @@ -116,6 +117,20 @@ pub trait UiAnalyzer { Ok(BulkChunkedTimelinesResponse { entries }) } + + /// Return, for every operator of a query, a binned timeline of a + /// distribution 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 reports the feature as unsupported, so + /// existing analyzers keep compiling and the UI hides the view. + fn data_flow_timeline( + &self, + _request: DistributionTimelineRequest, + ) -> AnalyzerResult { + Ok(ui::data_flow::DataFlowTimelineResponse::Unsupported) + } } /// Boxed owned stream of an analyzer's [`UiAnalyzer::Event`] from diff --git a/domains/query_engine/server/src/ui.rs b/domains/query_engine/server/src/ui.rs index 119155537..0b527663d 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::{ + distribution::DistributionTimelineRequest, 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 distribution timeline, or Unsupported", 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..b940aaf95 --- /dev/null +++ b/domains/query_engine/tests/fixed/tests/data_flow.rs @@ -0,0 +1,180 @@ +// 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::QueryFilter; +use quent_query_engine_ui::data_flow::{DataFlowTimelineBinned, DataFlowTimelineResponse}; +use quent_simulator_analyzer::SimulatorUiAnalyzer; +use quent_simulator_instrumentation::{SimulatorContext, test_utils::events_from_recorded}; +use quent_ui::timeline::{distribution::DistributionTimelineRequest, 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]) -> DistributionTimelineRequest { + DistributionTimelineRequest { + 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 binned(response: DataFlowTimelineResponse) -> DataFlowTimelineBinned { + match response { + DataFlowTimelineResponse::Binned(binned) => binned, + DataFlowTimelineResponse::Unsupported => panic!("expected Binned, got Unsupported"), + } +} + +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 = binned(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 = binned(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 = binned(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 = binned(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()); +} 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..18e5ae5f7 --- /dev/null +++ b/domains/query_engine/ui/src/data_flow.rs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Types for the per-operator data-flow distribution timeline. + +use std::collections::HashMap; + +use quent_time::bin::BinnedSpanSec; +use quent_ui::timeline::distribution::{DistributionDecl, DistributionSeries}; +use serde::Serialize; +use ts_rs::TS; +use uuid::Uuid; + +/// A binned data-flow distribution timeline covering every operator of a +/// query. +#[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: DistributionDecl, + /// Distribution series keyed by operator id. + pub operators: HashMap, +} + +/// Response for a data-flow distribution timeline request. +#[derive(TS, Debug, Clone, Serialize)] +pub enum DataFlowTimelineResponse { + /// This analyzer does not provide data-flow distributions; the UI hides + /// the corresponding view. + Unsupported, + Binned(DataFlowTimelineBinned), +} diff --git a/domains/query_engine/ui/src/lib.rs b/domains/query_engine/ui/src/lib.rs index 5f0d8204c..a1fe37e30 100644 --- a/domains/query_engine/ui/src/lib.rs +++ b/domains/query_engine/ui/src/lib.rs @@ -3,6 +3,8 @@ //! Types shared with the UI. +pub mod data_flow; + 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..d6105a5c8 100644 --- a/examples/simulator/analyzer/src/lib.rs +++ b/examples/simulator/analyzer/src/lib.rs @@ -7,11 +7,18 @@ use quent_query_engine_analyzer::{ entities, ui::{QuentViewer, UiAnalyzer, ViewerEventStream}, }; -use quent_query_engine_ui::{OperatorFilter, QueryBundle, QueryEntities, QueryFilter}; +use quent_query_engine_ui::{ + OperatorFilter, QueryBundle, QueryEntities, QueryFilter, + data_flow::{DataFlowTimelineBinned, DataFlowTimelineResponse}, +}; use quent_ui::{ FiniteStateMachine, ResourceGroupNode, ResourceTree, convert_resource_tree, - quantity::QuantitySpec, + quantity::{CapacityKind, QuantitySpec}, timeline::{ + distribution::{ + DimensionKeyDecl, DistributionDecl, DistributionSeries, DistributionTimelineRequest, + MeasureDecl, + }, request::{ BulkChunkedTimelineRequest, BulkTimelineRequest, EntityFilter, SingleTimelineRequest, TimelineRequest, @@ -30,18 +37,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::{ + distribution::{DistributionKey, DistributionTimelineBuilder}, + 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 +63,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 +793,173 @@ impl UiAnalyzer for SimulatorUiAnalyzer { Ok(BulkChunkedTimelinesResponse { entries }) } + + fn data_flow_timeline( + &self, + request: DistributionTimelineRequest, + ) -> 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. + 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); + if !want_tasks && !want_bytes { + return Err(AnalyzerError::InvalidArgument(format!( + "unknown measures {:?}; declared measures are '{MEASURE_TASKS}' and '{MEASURE_BYTES}'", + request.measures + ))); + } + + 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(); + + let mut builder = DistributionTimelineBuilder::::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(DIMENSION_NONE, |u| memory_names[&u.resource_id]); + if want_tasks { + builder.try_push( + DistributionKey { + 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 { + builder.try_push( + DistributionKey { + 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 mut memory_instance_names: Vec<&str> = memory_names + .values() + .copied() + .collect::>() + .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(); + dimension_keys.push(DimensionKeyDecl { + key: DIMENSION_NONE.to_owned(), + 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(DataFlowTimelineResponse::Binned(DataFlowTimelineBinned { + config: config.try_to_secs_relative(epoch)?, + decl: DistributionDecl { + entity_type_name: Task::fsm_type_declaration().name, + dimension_name: "Data location".to_owned(), + dimension_keys, + measures, + }, + operators, + })) + } } impl SimulatorUiAnalyzer { diff --git a/examples/simulator/server/build.rs b/examples/simulator/server/build.rs index b8c5df33b..80c6f6b7a 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::data_flow::DataFlowTimelineResponse; 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::{ + distribution::DistributionTimelineRequest, 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/DataFlowTimelineBinned.ts b/examples/simulator/server/ts-bindings/DataFlowTimelineBinned.ts new file mode 100644 index 000000000..d94b51a98 --- /dev/null +++ b/examples/simulator/server/ts-bindings/DataFlowTimelineBinned.ts @@ -0,0 +1,25 @@ +// 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 { DistributionDecl } from "./DistributionDecl"; +import type { DistributionSeries } from "./DistributionSeries"; + +/** + * A binned data-flow distribution timeline covering every operator of a + * query. + */ +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: DistributionDecl, +/** + * Distribution series keyed by operator id. + */ +operators: { [key in string]: DistributionSeries }, }; diff --git a/examples/simulator/server/ts-bindings/DataFlowTimelineResponse.ts b/examples/simulator/server/ts-bindings/DataFlowTimelineResponse.ts new file mode 100644 index 000000000..c1a6d1b4c --- /dev/null +++ b/examples/simulator/server/ts-bindings/DataFlowTimelineResponse.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DataFlowTimelineBinned } from "./DataFlowTimelineBinned"; + +/** + * Response for a data-flow distribution timeline request. + */ +export type DataFlowTimelineResponse = "Unsupported" | { "Binned": DataFlowTimelineBinned }; diff --git a/examples/simulator/server/ts-bindings/DimensionKeyDecl.ts b/examples/simulator/server/ts-bindings/DimensionKeyDecl.ts new file mode 100644 index 000000000..7e7801b7e --- /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 distribution timeline. + */ +export type DimensionKeyDecl = { +/** + * The key used in [`DistributionSeries::values`]. + */ +key: string, +/** + * Human-friendly display name. + */ +display_name: string, }; diff --git a/examples/simulator/server/ts-bindings/DistributionDecl.ts b/examples/simulator/server/ts-bindings/DistributionDecl.ts new file mode 100644 index 000000000..f0e691892 --- /dev/null +++ b/examples/simulator/server/ts-bindings/DistributionDecl.ts @@ -0,0 +1,31 @@ +// 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 distribution 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 DistributionDecl = { +/** + * The FSM type whose states are distributed. 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, }; diff --git a/examples/simulator/server/ts-bindings/DistributionSeries.ts b/examples/simulator/server/ts-bindings/DistributionSeries.ts new file mode 100644 index 000000000..b96f1b5fa --- /dev/null +++ b/examples/simulator/server/ts-bindings/DistributionSeries.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 distribution timeline series: + * measure name -> state name -> dimension key -> one value per time bin. + * + * Absent inner entries mean all-zero bins. + */ +export type DistributionSeries = { values: { [key in string]: { [key in string]: { [key in string]: Array } } }, }; diff --git a/examples/simulator/server/ts-bindings/DistributionTimelineRequest.ts b/examples/simulator/server/ts-bindings/DistributionTimelineRequest.ts new file mode 100644 index 000000000..41863d522 --- /dev/null +++ b/examples/simulator/server/ts-bindings/DistributionTimelineRequest.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 distribution timeline. + */ +export type DistributionTimelineRequest = { +/** + * 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/MeasureDecl.ts b/examples/simulator/server/ts-bindings/MeasureDecl.ts new file mode 100644 index 000000000..f28dac5c4 --- /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 distribution timeline, + * e.g. an entity count or a number of bytes. + */ +export type MeasureDecl = { +/** + * Unique name; key into [`DistributionSeries::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, }; From 81d2ffe95bb46c3e6105088eb19e61a5e3d42fef Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Tue, 14 Jul 2026 15:10:01 -0500 Subject: [PATCH 02/14] feat(ui): DAG data-flow timeline playhead and per-node distribution bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the UI half of the data-flow distribution protocol: a time slider (playhead) under the query-plan DAG that, at time T, shows on every DAG node a mini stacked bar of entities per FSM state plus a thin bar of the server-declared dimension breakdown (e.g. data location / memory tier). Scrubbing animates where data accumulates. Everything is server-declared via POST /api/engines/{id}/timeline/data-flow — no hardcoded state, dimension, or measure names in the UI; "Unsupported"/empty responses hide the feature entirely. - @quent/utils: re-export the new ts-bindings (DataFlowTimelineResponse, DistributionDecl/Series, DistributionTimelineRequest, MeasureDecl, DimensionKeyDecl). - @quent/client: fetchDataFlow + dataFlowQueryOptions/useDataFlow (keepPreviousData so zoom refetches don't flicker). - @quent/hooks: private data-flow atoms (HOOKS-02) + selector hooks, pure unit-tested helpers (normalize/window/bin-index/windowMax/frame extraction), and useDataFlowSync which keeps the raw response in react-query, clamps the playhead into the window, and recomputes the per-bin frame inside startTransition via store.sub — the host component never re-renders on scrub. - @quent/components: DagPlayhead (plain DOM slider: play/pause, pointer capture + rAF-throttled drag, keyboard slider semantics, synced crosshair on timeline charts via new broadcastSyncedPointer), NodeFlowBar (only node-level frame subscriber; window-max-stable width, constant height when empty), plus DAGControls toggle/measure select, DAGLegend state+dimension groups, and a state × dimension matrix in DAGNodeInfoPanel. - app: QueryPlan wires zoom window → useDataFlow → useDataFlowSync and renders the playhead between the DAG canvas and the info panel. Verified against the simulator server (endpoint smoke test through the vite proxy) with typecheck, lint, build, and 499 vitest tests green. Co-Authored-By: Claude Fable 5 --- ui/packages/@quent/client/src/api.ts | 28 ++ ui/packages/@quent/client/src/dataFlow.ts | 35 ++ ui/packages/@quent/client/src/index.ts | 3 + .../@quent/components/src/dag/DAGChart.tsx | 10 +- .../@quent/components/src/dag/DAGControls.tsx | 47 ++- .../@quent/components/src/dag/DAGLegend.tsx | 51 ++- .../components/src/dag/DAGNodeInfoPanel.tsx | 142 +++++++- .../@quent/components/src/dag/DagPlayhead.tsx | 248 ++++++++++++++ ui/packages/@quent/components/src/index.ts | 4 + .../components/src/lib/timeline.utils.ts | 19 +- .../components/src/query-plan/NodeFlowBar.tsx | 99 ++++++ .../src/query-plan/QueryPlanNode.tsx | 9 + .../@quent/hooks/src/atoms/dataFlow.ts | 35 ++ .../hooks/src/dataFlow/dataFlow.utils.test.ts | 314 ++++++++++++++++++ .../hooks/src/dataFlow/dataFlow.utils.ts | 271 +++++++++++++++ .../hooks/src/dataFlow/dataFlowSelectors.ts | 25 ++ .../hooks/src/dataFlow/useDataFlowSync.ts | 115 +++++++ ui/packages/@quent/hooks/src/index.ts | 26 ++ ui/packages/@quent/utils/src/types/index.ts | 6 + ui/src/components/DataFlowOverlay.test.tsx | 119 +++++++ ui/src/components/QueryPlan.tsx | 31 +- 21 files changed, 1624 insertions(+), 13 deletions(-) create mode 100644 ui/packages/@quent/client/src/dataFlow.ts create mode 100644 ui/packages/@quent/components/src/dag/DagPlayhead.tsx create mode 100644 ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx create mode 100644 ui/packages/@quent/hooks/src/atoms/dataFlow.ts create mode 100644 ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts create mode 100644 ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts create mode 100644 ui/packages/@quent/hooks/src/dataFlow/dataFlowSelectors.ts create mode 100644 ui/packages/@quent/hooks/src/dataFlow/useDataFlowSync.ts create mode 100644 ui/src/components/DataFlowOverlay.test.tsx diff --git a/ui/packages/@quent/client/src/api.ts b/ui/packages/@quent/client/src/api.ts index 9306010a7..f6375e84b 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, + DataFlowTimelineResponse, + DistributionTimelineRequest, QueryFilter, OperatorFilter, EntityRef, Engine, + TimelineConfig, } from '@quent/utils'; interface ApiFetchOptions { @@ -104,3 +107,28 @@ export async function fetchBulkTimelines( }, }); } + +/** + * Fetch the data-flow distribution timeline for a query (all operators in one + * response). Returns `"Unsupported"` when the engine's analyzer does not + * implement the data-flow protocol. + * @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: DistributionTimelineRequest = { + measures, + config, + app_params: { query_id: queryId }, + }; + return apiFetch(`/engines/${engineId}/timeline/data-flow`, { + fetchOptions: { + method: 'POST', + body: JSON.stringify(request), + }, + }); +} diff --git a/ui/packages/@quent/client/src/dataFlow.ts b/ui/packages/@quent/client/src/dataFlow.ts new file mode 100644 index 000000000..4ef141754 --- /dev/null +++ b/ui/packages/@quent/client/src/dataFlow.ts @@ -0,0 +1,35 @@ +// 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], + 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/DAGChart.tsx b/ui/packages/@quent/components/src/dag/DAGChart.tsx index 3f77a5fac..cf0aec87f 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 => { diff --git a/ui/packages/@quent/components/src/dag/DAGControls.tsx b/ui/packages/@quent/components/src/dag/DAGControls.tsx index f11e26d34..f225a6dc5 100644 --- a/ui/packages/@quent/components/src/dag/DAGControls.tsx +++ b/ui/packages/@quent/components/src/dag/DAGControls.tsx @@ -10,6 +10,12 @@ import { useSelectedDagLayoutDirection, useNodeColorPalette, useEdgeColorPalette, + useDataFlowEnabled, + useSetDataFlowEnabled, + useDataFlowMeta, + useSelectedDataFlowMeasure, + useSetSelectedDataFlowMeasure, + resolveDataFlowMeasure, } from '@quent/hooks'; import { NODE_LABEL_FIELD, @@ -17,7 +23,7 @@ import { type NodeLabelField, type DagLayoutDirection, } from '@quent/utils'; -import { Palette, Spline, Brush, Type, ArrowUpDown } from 'lucide-react'; +import { Palette, Spline, Brush, Type, ArrowUpDown, Activity, Gauge } from 'lucide-react'; import { PalettePicker } from './PalettePicker'; interface DAGControlsProps { @@ -47,10 +53,23 @@ 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 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; + return (
@@ -112,6 +131,32 @@ 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" + /> + )}
); diff --git a/ui/packages/@quent/components/src/dag/DAGLegend.tsx b/ui/packages/@quent/components/src/dag/DAGLegend.tsx index 41545df76..1d53a8fdb 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,8 +10,15 @@ import { useEdgeColorPalette, useSelectedColorField, useSelectedEdgeColorField, + useDataFlowEnabled, + useDataFlowMeta, } from '@quent/hooks'; -import { getLegendGradientStops } from '@quent/utils'; +import { + createCapacitiesColorFn, + createFsmTypeColorFn, + getLegendGradientStops, + type PaletteTheme, +} from '@quent/utils'; import { inferFieldFormatter } from '@quent/utils'; import type { NodeColoring, EdgeColoring } from '../services/query-plan/types'; import type { ContinuousPaletteName } from '@quent/utils'; @@ -146,11 +154,37 @@ 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 = createFsmTypeColorFn( + dataFlowMeta.fsmType ? { [dataFlowMeta.fsmType.name]: dataFlowMeta.fsmType } : {}, + 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]); 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 +202,19 @@ 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 b5272cdb5..9039deb86 100644 --- a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx +++ b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx @@ -1,17 +1,143 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { ChevronUp, ChevronDown } from 'lucide-react'; -import { useSelectedNodeData } from '@quent/hooks'; +import { + useSelectedNodeData, + useDataFlowEnabled, + useDataFlowMeta, + useDataFlowFrame, + formatDataFlowValue, + type DataFlowFrame, + type DataFlowMeta, + type DataFlowOperatorFrame, +} from '@quent/hooks'; import { DataText } from '../ui/data-text'; import { thinScrollbarClass } from '../ui/thin-scroll'; -import { inferFieldFormatter } from '@quent/utils'; +import { + createCapacitiesColorFn, + createFsmTypeColorFn, + formatDuration, + inferFieldFormatter, + type PaletteTheme, +} from '@quent/utils'; -export const DAGNodeInfoPanel = () => { +const ColorDot = ({ color }: { color: string }) => ( + +); + +/** + * 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. + */ +const DataFlowMatrix = ({ + meta, + frame, + operatorFrame, + isDark, +}: { + meta: DataFlowMeta; + frame: DataFlowFrame; + operatorFrame: DataFlowOperatorFrame; + isDark: boolean; +}) => { + const paletteTheme: PaletteTheme = isDark ? 'dark' : 'light'; + const dimensionKeys = meta.decl.dimension_keys; + const stateColor = useMemo( + () => + createFsmTypeColorFn(meta.fsmType ? { [meta.fsmType.name]: meta.fsmType } : {}, paletteTheme), + [meta, paletteTheme] + ); + const dimensionColor = useMemo( + () => + createCapacitiesColorFn( + dimensionKeys.map(k => k.key), + paletteTheme + ), + [dimensionKeys, 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 + +
+ + + + + {dimensionKeys.map(k => ( + + ))} + + + + + {meta.stateNames.map((state, stateIndex) => ( + + + {dimensionKeys.map((k, dimensionIndex) => ( + + ))} + + + ))} + + + {dimensionKeys.map((k, dimensionIndex) => ( + + ))} + + + +
+ {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)} +
+
+ ); +}; + +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(() => { if (!selectedNodeData) { setIsExpanded(false); @@ -51,6 +177,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..c8e1709ab --- /dev/null +++ b/ui/packages/@quent/components/src/dag/DagPlayhead.tsx @@ -0,0 +1,248 @@ +// 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]); + + // 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/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 e189b02f1..c821820e4 100644 --- a/ui/packages/@quent/components/src/lib/timeline.utils.ts +++ b/ui/packages/@quent/components/src/lib/timeline.utils.ts @@ -426,7 +426,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 { @@ -450,7 +450,7 @@ function broadcastShowPointer(source: EChartsInstance, timestampMs: number) { } } -function broadcastHidePointer(source: EChartsInstance) { +function broadcastHidePointer(source: EChartsInstance | null) { if (isBroadcasting) return; isBroadcasting = true; try { @@ -467,6 +467,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..bf1d47879 --- /dev/null +++ b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { memo, useMemo } from 'react'; +import { + createCapacitiesColorFn, + createFsmTypeColorFn, + type FsmTypeDecl, + type PaletteTheme, +} from '@quent/utils'; +import { useDataFlowFrame, useDataFlowMeta, formatDataFlowValue } from '@quent/hooks'; + +const BAR_TRANSITION = 'width 120ms linear'; + +/** State colors keyed on the FSM type declaration — matches the timeline view. */ +function fsmTypesMapOf(fsmType: FsmTypeDecl | null): { [key in string]?: FsmTypeDecl } { + return fsmType ? { [fsmType.name]: fsmType } : {}; +} + +/** + * Per-node data-flow overlay: a stacked state bar over a thin dimension bar, + * plus a tiny total label. 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, + * so scrubbing never causes layout churn. + */ +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 stateColor = useMemo( + () => createFsmTypeColorFn(fsmTypesMapOf(fsmType), theme), + [fsmType, 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'; + + return ( +
+
+
+ {hasData && + meta.stateNames.map((state, stateIndex) => { + const value = operatorFrame.byState[stateIndex] ?? 0; + if (value <= 0) return null; + return ( +
+ ); + })} +
+
+
+
+ {hasData && + meta.decl.dimension_keys.map((dimension, dimensionIndex) => { + const value = operatorFrame.byDimension[dimensionIndex] ?? 0; + if (value <= 0) return null; + return ( +
+ ); + })} +
+
+
+ {hasData ? formatDataFlowValue(total, frame.measure, meta) : '\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/hooks/src/atoms/dataFlow.ts b/ui/packages/@quent/hooks/src/atoms/dataFlow.ts new file mode 100644 index 000000000..33385b4ba --- /dev/null +++ b/ui/packages/@quent/hooks/src/atoms/dataFlow.ts @@ -0,0 +1,35 @@ +// 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); + +/** + * 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..3df135c91 --- /dev/null +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts @@ -0,0 +1,314 @@ +// 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, + formatDataFlowValue, + isDataFlowAvailable, + normalizeDataFlowResponse, + 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' }, + ], + }, + 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 BIN: DataFlowBinConfig = { startS: 0, endS: 8, binDurationS: 2, numBins: NUM_BINS }; + +describe('normalizeDataFlowResponse', () => { + it('returns null for "Unsupported"', () => { + expect(normalizeDataFlowResponse('Unsupported')).toBeNull(); + }); + + it('returns null for null/undefined', () => { + expect(normalizeDataFlowResponse(null)).toBeNull(); + expect(normalizeDataFlowResponse(undefined)).toBeNull(); + }); + + it('unwraps the Binned variant', () => { + const binned = makeBinned(OPERATORS); + expect(normalizeDataFlowResponse({ Binned: binned })).toBe(binned); + }); +}); + +describe('isDataFlowAvailable', () => { + it('is false for "Unsupported"', () => { + expect(isDataFlowAvailable('Unsupported')).toBe(false); + }); + + it('is false for an empty operators map', () => { + expect(isDataFlowAvailable({ Binned: makeBinned({}) })).toBe(false); + }); + + it('is true for a non-empty Binned response', () => { + expect(isDataFlowAvailable({ Binned: 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('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); + }); +}); + +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); + }); +}); + +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.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']); + }); +}); + +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('returns null when no measures are declared', () => { + expect(resolveDataFlowMeasure(null, { ...decl, measures: [] })).toBeNull(); + }); +}); + +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'); + }); +}); 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..3566d5e37 --- /dev/null +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts @@ -0,0 +1,271 @@ +// 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 `DistributionDecl` — no hardcoded semantics. + +import type { + DataFlowTimelineBinned, + DataFlowTimelineResponse, + DistributionDecl, + FsmTypeDecl, + QuantitySpec, + ZoomRange, +} from '@quent/utils'; +import { formatQuantity } 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: DistributionDecl; + /** + * 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. + */ + windowMax: Record; + /** Quantity specs (from the query bundle) keyed by quantity name. */ + quantitySpecs: { [key in string]?: QuantitySpec }; +} + +/** Per-operator distribution values at one bin. */ +export interface DataFlowOperatorFrame { + /** Sum over all states and 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[][]; +} + +/** 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. */ + measure: string; + /** Window max for the measure (see {@link DataFlowMeta.windowMax}). */ + maxTotal: number; + /** Operators with a non-zero total at this bin. */ + perOperator: Map; +} + +/** + * Normalize the externally-tagged response. Returns `null` for + * `"Unsupported"` or malformed values. + */ +export function normalizeDataFlowResponse( + response: DataFlowTimelineResponse | null | undefined +): DataFlowTimelineBinned | null { + if (!response || response === 'Unsupported') return null; + if (typeof response !== 'object' || !('Binned' in response)) return null; + return response.Binned; +} + +/** Whether the feature should be shown at all: supported and non-empty. */ +export function isDataFlowAvailable( + response: DataFlowTimelineResponse | 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]; +} + +/** + * Max operator total (summed over states and dimension keys) across all bins + * of the window for one measure. Missing entries count as zero. + */ +export function computeWindowMax(binned: DataFlowTimelineBinned, measure: string): number { + const numBins = Number(binned.config.num_bins); + 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 values of Object.values(dims)) { + 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; +} + +/** + * 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. + */ +export function extractDataFlowFrame( + binned: DataFlowTimelineBinned, + stateNames: string[], + measure: string, + binIndex: number, + maxTotal: number +): DataFlowFrame { + 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 perOperator = new Map(); + + for (const [operatorId, series] of Object.entries(binned.operators)) { + 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) => { + 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) + ); + perOperator.set(operatorId, { total, byState, byDimension, matrix }); + } + + return { + binIndex: clamped, + timeS: bin.startS + clamped * bin.binDurationS, + measure, + maxTotal, + perOperator, + }; +} + +/** Build the presentation metadata for one normalized response. */ +export function buildDataFlowMeta( + binned: DataFlowTimelineBinned, + fsmTypes: { [key in string]?: FsmTypeDecl } | undefined, + quantitySpecs: { [key in string]?: QuantitySpec } | undefined +): DataFlowMeta { + const fsmType = fsmTypes?.[binned.decl.entity_type_name] ?? null; + const windowMax: Record = {}; + for (const measure of binned.decl.measures) { + windowMax[measure.name] = computeWindowMax(binned, measure.name); + } + return { + decl: binned.decl, + fsmType, + stateNames: resolveDataFlowStates(binned, fsmType), + bin: extractBinConfig(binned), + windowMax, + quantitySpecs: quantitySpecs ?? {}, + }; +} + +/** + * Resolve the effective measure: the selected one when it is declared, + * otherwise the first declared measure (or `null` when none exist). + */ +export function resolveDataFlowMeasure( + selected: string | null, + decl: DistributionDecl +): string | null { + if (selected != null && decl.measures.some(m => m.name === selected)) return selected; + return decl.measures[0]?.name ?? null; +} + +/** + * 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); +} 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..8e66b82a7 --- /dev/null +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlowSelectors.ts @@ -0,0 +1,25 @@ +// 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, + 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 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..c93226d91 --- /dev/null +++ b/ui/packages/@quent/hooks/src/dataFlow/useDataFlowSync.ts @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { startTransition, useEffect, useMemo } from 'react'; +import { useStore } from 'jotai'; +import type { DataFlowTimelineResponse, EntityRef, QueryBundle } from '@quent/utils'; +import { + buildDataFlowMeta, + extractDataFlowFrame, + normalizeDataFlowResponse, + resolveDataFlowMeasure, + timeToBinIndex, +} from './dataFlow.utils'; +import { + dataFlowFrameAtom, + dataFlowMetaAtom, + playheadTimeSAtom, + selectedDataFlowMeasureAtom, +} from '../atoms/dataFlow'; + +/** + * Synchronizes the data-flow response into the private data-flow atoms: + * + * - writes {@link dataFlowMetaAtom} whenever the response changes + * - initializes/clamps the playhead into the response window + * - recomputes {@link dataFlowFrameAtom} on (response, measure, bin) change + * + * The raw response stays in react-query — no raw-response atom. Playhead + * changes are observed via `store.sub`, so the component calling this hook + * does NOT re-render on scrub; frame writes happen inside + * `startTransition` so a fast drag stays responsive. + * + * @returns whether the feature is available (supported and non-empty). + */ +export function useDataFlowSync({ + response, + queryBundle, +}: { + response: DataFlowTimelineResponse | null | undefined; + queryBundle: QueryBundle | null | undefined; +}): { available: boolean } { + const store = useStore(); + + const normalized = useMemo(() => normalizeDataFlowResponse(response), [response]); + const fsmTypes = queryBundle?.entities.fsm_types; + const quantitySpecs = queryBundle?.quantity_specs; + + const meta = useMemo( + () => + normalized && Object.keys(normalized.operators).length > 0 + ? buildDataFlowMeta(normalized, fsmTypes, quantitySpecs) + : null, + [normalized, fsmTypes, quantitySpecs] + ); + + // Publish meta and keep the playhead inside the current window. + useEffect(() => { + store.set(dataFlowMetaAtom, meta); + if (!meta) { + store.set(dataFlowFrameAtom, null); + return; + } + const playhead = store.get(playheadTimeSAtom); + const clamped = + playhead == null + ? meta.bin.startS + : Math.min(Math.max(playhead, meta.bin.startS), meta.bin.endS); + if (clamped !== playhead) store.set(playheadTimeSAtom, clamped); + }, [meta, store]); + + // Recompute the frame when the playhead crosses a bin boundary or the + // selected measure changes. Subscribing imperatively (instead of + // useAtomValue) keeps the host component from re-rendering on scrub. + useEffect(() => { + if (!normalized || !meta) return; + + let lastBinIndex = -1; + let lastMeasure: string | null = null; + + const recompute = () => { + const measure = resolveDataFlowMeasure(store.get(selectedDataFlowMeasureAtom), meta.decl); + if (measure == null) { + lastBinIndex = -1; + lastMeasure = null; + store.set(dataFlowFrameAtom, null); + return; + } + const playhead = store.get(playheadTimeSAtom) ?? meta.bin.startS; + const binIndex = timeToBinIndex(playhead, meta.bin); + if (binIndex === lastBinIndex && measure === lastMeasure) return; + lastBinIndex = binIndex; + lastMeasure = measure; + const frame = extractDataFlowFrame( + normalized, + meta.stateNames, + measure, + binIndex, + meta.windowMax[measure] ?? 0 + ); + startTransition(() => { + store.set(dataFlowFrameAtom, frame); + }); + }; + + recompute(); + const unsubPlayhead = store.sub(playheadTimeSAtom, recompute); + const unsubMeasure = store.sub(selectedDataFlowMeasureAtom, recompute); + return () => { + unsubPlayhead(); + unsubMeasure(); + }; + }, [normalized, meta, store]); + + return { available: meta != null }; +} diff --git a/ui/packages/@quent/hooks/src/index.ts b/ui/packages/@quent/hooks/src/index.ts index 96e2d1cc8..aa9ff1363 100644 --- a/ui/packages/@quent/hooks/src/index.ts +++ b/ui/packages/@quent/hooks/src/index.ts @@ -93,6 +93,32 @@ export type { InspectedNodeData, } from './atoms/dagControls'; +// Data-flow overlay hooks (HOOKS-02: selector hooks over private atoms) +export { + useDataFlowEnabled, + useSetDataFlowEnabled, + usePlayheadTimeS, + useSetPlayheadTimeS, + useSelectedDataFlowMeasure, + useSetSelectedDataFlowMeasure, + useDataFlowMeta, + useDataFlowFrame, +} from './dataFlow/dataFlowSelectors'; +export { useDataFlowSync } from './dataFlow/useDataFlowSync'; +export { + normalizeDataFlowResponse, + isDataFlowAvailable, + resolveDataFlowWindow, + resolveDataFlowMeasure, + formatDataFlowValue, +} 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/types/index.ts b/ui/packages/@quent/utils/src/types/index.ts index c2f46d225..14d825d78 100644 --- a/ui/packages/@quent/utils/src/types/index.ts +++ b/ui/packages/@quent/utils/src/types/index.ts @@ -8,6 +8,12 @@ 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 { DataFlowTimelineBinned } from '../../../../../../examples/simulator/server/ts-bindings/DataFlowTimelineBinned'; +export type { DataFlowTimelineResponse } from '../../../../../../examples/simulator/server/ts-bindings/DataFlowTimelineResponse'; +export type { DimensionKeyDecl } from '../../../../../../examples/simulator/server/ts-bindings/DimensionKeyDecl'; +export type { DistributionDecl } from '../../../../../../examples/simulator/server/ts-bindings/DistributionDecl'; +export type { DistributionSeries } from '../../../../../../examples/simulator/server/ts-bindings/DistributionSeries'; +export type { DistributionTimelineRequest } from '../../../../../../examples/simulator/server/ts-bindings/DistributionTimelineRequest'; 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..ea178cd8b --- /dev/null +++ b/ui/src/components/DataFlowOverlay.test.tsx @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, it, expect } from 'vitest'; +import { Provider } from 'jotai'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useDataFlowSync } from '@quent/hooks'; +import { DagPlayhead, NodeFlowBar } from '@quent/components'; +import type { DataFlowTimelineResponse, EntityRef, QueryBundle } from '@quent/utils'; + +// 4 bins of 2s over [0, 8): op-1 totals per bin are [1, 3, 5, 0]. +const RESPONSE: DataFlowTimelineResponse = { + Binned: { + 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' }], + }, + operators: { + 'op-1': { + values: { + tasks: { + queueing: { memory: [1, 2, 0, 0] }, + computing: { memory: [0, 1, 3, 0], filesystem: [0, 0, 2, 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', + }, + }, +} as unknown as QueryBundle; + +function Harness({ response }: { response: DataFlowTimelineResponse }) { + useDataFlowSync({ response, queryBundle: QUERY_BUNDLE }); + return ( + <> + + + + ); +} + +function renderOverlay(response: DataFlowTimelineResponse) { + return render( + + + + ); +} + +describe('data-flow overlay components', () => { + it('renders nothing when the response is "Unsupported"', () => { + renderOverlay('Unsupported'); + 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 the operator total at the current bin in the flow bar', () => { + renderOverlay(RESPONSE); + // Bin 0: queueing/memory = 1. + expect(screen.getByTestId('node-flow-bar')).toHaveTextContent('1.0'); + }); + + it('advances one bin per ArrowRight and updates the flow bar', () => { + renderOverlay(RESPONSE); + const slider = screen.getByRole('slider'); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + expect(slider).toHaveAttribute('aria-valuenow', '2'); + // Bin 1: queueing 2 + computing 1 = 3. + expect(screen.getByTestId('node-flow-bar')).toHaveTextContent('3.0'); + }); + + 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: label collapses to a non-breaking space. + const bar = screen.getByTestId('node-flow-bar'); + expect(bar).not.toHaveTextContent('1.0'); + expect(bar.textContent).toContain('\u00A0'); + }); +}); diff --git a/ui/src/components/QueryPlan.tsx b/ui/src/components/QueryPlan.tsx index d6a334304..a2d89d362 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,26 @@ export function QueryPlan({ queryId, engineId }: { queryId: string; engineId: st const { dagData, treeData, error: dagError } = useQueryPlanVisualization(queryBundle, planId); + // Data-flow overlay: fetch the distribution 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 — `"Unsupported"` 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 +212,8 @@ export function QueryPlan({ queryId, engineId }: { queryId: string; engineId: st
- + +
From fb08a6c5b5f1575bc9494a25b539cf8ea64dd70c Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Wed, 15 Jul 2026 12:00:47 -0500 Subject: [PATCH 03/14] feat(ui): in-segment value labels and dual-measure totals on DAG flow bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node flow bar improvements for the DAG data-flow overlay: - Render each state segment's value inside its colored segment, width-gated purely from frame data (segment px = value/windowMax * 168px track at ~6px/char + 4px pad) — no DOM measurement, labels hide when the segment is too narrow, and absolute positioning inside overflow-hidden segments means zero layout shift. The state bar grows 6px -> 12px to fit legible 8px labels; text color flips by segment luminance (dark text on light colors, white + subtle shadow on dark) for both themes. - The tiny per-node total now shows EVERY declared measure with data at the playhead bin, joined as e.g. "3.2 · 1.4MiB" (count · bytes), still one right-aligned truncating line that collapses to nbsp when empty. - New compact formatters: formatCompactWithPrefix / formatQuantityCompact (2-3 significant digits, no space, prefix+symbol only: "482", "1.2k", "45MiB") and formatDataFlowValueCompact / fitDataFlowSegmentLabel on top. - extractDataFlowFrame now also returns totalsByMeasure (per-operator totals for all declared measures at the bin, zero measures omitted) in the same cheap per-scrub pass. - vitest.config.ts: mirror vite.config.ts resolve.dedupe — without it the workspace packages load their own jotai copies, so a test never scopes @quent/hooks atoms and playhead state leaks between tests through jotai's global default store. Co-Authored-By: Claude Fable 5 --- .../components/src/query-plan/NodeFlowBar.tsx | 74 +++++++++++--- .../hooks/src/dataFlow/dataFlow.utils.test.ts | 98 +++++++++++++++++++ .../hooks/src/dataFlow/dataFlow.utils.ts | 86 +++++++++++++++- ui/packages/@quent/hooks/src/index.ts | 2 + .../@quent/utils/src/formatters.test.ts | 59 +++++++++++ ui/packages/@quent/utils/src/formatters.ts | 59 +++++++++++ ui/packages/@quent/utils/src/index.ts | 2 + ui/src/components/DataFlowOverlay.test.tsx | 75 +++++++++++--- ui/vitest.config.ts | 6 ++ 9 files changed, 438 insertions(+), 23 deletions(-) diff --git a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx index bf1d47879..6a1ea4c2a 100644 --- a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx +++ b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx @@ -5,26 +5,42 @@ import { memo, useMemo } from 'react'; import { createCapacitiesColorFn, createFsmTypeColorFn, + isLightColor, type FsmTypeDecl, type PaletteTheme, } from '@quent/utils'; -import { useDataFlowFrame, useDataFlowMeta, formatDataFlowValue } from '@quent/hooks'; +import { + useDataFlowFrame, + useDataFlowMeta, + fitDataFlowSegmentLabel, + formatDataFlowValueCompact, +} from '@quent/hooks'; +import { NODE_LAYOUT_WIDTH } from '../dag/layout'; 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. */ function fsmTypesMapOf(fsmType: FsmTypeDecl | null): { [key in string]?: FsmTypeDecl } { return fsmType ? { [fsmType.name]: fsmType } : {}; } /** - * Per-node data-flow overlay: a stacked state bar over a thin dimension bar, - * plus a tiny total label. 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. + * Per-node data-flow overlay: a stacked state bar (with width-gated + * in-segment value labels) over a thin dimension bar, plus a tiny totals + * label covering every declared measure. 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, - * so scrubbing never causes layout churn. + * so scrubbing never causes layout churn. Labels are absolutely positioned + * inside overflow-hidden segments, so their appearance never shifts layout. */ export const NodeFlowBar = memo( ({ operatorId, isDark }: { operatorId: string; isDark: boolean }) => { @@ -56,19 +72,52 @@ export const NodeFlowBar = memo( // 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 · 45MiB" (count · bytes). + 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 + ); return (
+ className="relative overflow-hidden" + style={{ flexGrow: value, backgroundColor: color }} + > + {label != null && ( + + {label} + + )} +
); })}
@@ -88,8 +137,11 @@ export const NodeFlowBar = memo( })}
-
- {hasData ? formatDataFlowValue(total, frame.measure, meta) : '\u00A0'} +
+ {totalsLabel !== '' ? totalsLabel : '\u00A0'}
); diff --git a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts index 3df135c91..969dce522 100644 --- a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts @@ -8,7 +8,9 @@ import { computeWindowMax, extractBinConfig, extractDataFlowFrame, + fitDataFlowSegmentLabel, formatDataFlowValue, + formatDataFlowValueCompact, isDataFlowAvailable, normalizeDataFlowResponse, resolveDataFlowMeasure, @@ -93,6 +95,14 @@ const UNIT_SPEC: QuantitySpec = { 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', () => { @@ -264,6 +274,34 @@ describe('extractDataFlowFrame', () => { 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); + }); }); describe('buildDataFlowMeta', () => { @@ -312,3 +350,63 @@ describe('formatDataFlowValue', () => { 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(); + }); +}); diff --git a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts index 3566d5e37..24156e30f 100644 --- a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts @@ -15,7 +15,7 @@ import type { QuantitySpec, ZoomRange, } from '@quent/utils'; -import { formatQuantity } 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 { @@ -74,6 +74,13 @@ export interface DataFlowFrame { 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) — 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>; } /** @@ -176,6 +183,10 @@ export function computeWindowMax(binned: DataFlowTimelineBinned, measure: string * 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 (a single cheap pass, recomputed per + * scrub tick). */ export function extractDataFlowFrame( binned: DataFlowTimelineBinned, @@ -187,9 +198,32 @@ export function extractDataFlowFrame( 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 measureNames = binned.decl.measures.map(m => m.name); const perOperator = new Map(); + const totalsByMeasure = new Map>(); for (const [operatorId, series] of Object.entries(binned.operators)) { + // Totals at this bin for every declared measure (selected or not). + const totals: Record = {}; + let hasAnyMeasure = false; + for (const measureName of measureNames) { + const measureStates = series.values[measureName]; + if (!measureStates) continue; + let measureTotal = 0; + for (const state of stateNames) { + const dims = measureStates[state]; + if (!dims) continue; + for (const dimension of dimensionKeys) { + measureTotal += dims[dimension]?.[clamped] ?? 0; + } + } + 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)); @@ -217,6 +251,7 @@ export function extractDataFlowFrame( measure, maxTotal, perOperator, + totalsByMeasure, }; } @@ -269,3 +304,52 @@ export function formatDataFlowValue( 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 state segment of the node flow bar. + * + * The bar's filled width is `total / maxTotal` of the track and each state + * 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. + */ +export function fitDataFlowSegmentLabel( + value: number, + maxTotal: number, + measureName: string, + meta: DataFlowMeta, + trackPx: number +): string | null { + if (!(value > 0) || !(maxTotal > 0) || !(trackPx > 0)) return null; + const segmentPx = (value / maxTotal) * trackPx; + const label = formatDataFlowValueCompact(value, measureName, meta); + const requiredPx = label.length * DATA_FLOW_LABEL_CHAR_PX + DATA_FLOW_LABEL_PAD_PX; + return segmentPx >= requiredPx ? label : null; +} diff --git a/ui/packages/@quent/hooks/src/index.ts b/ui/packages/@quent/hooks/src/index.ts index aa9ff1363..4cd610452 100644 --- a/ui/packages/@quent/hooks/src/index.ts +++ b/ui/packages/@quent/hooks/src/index.ts @@ -111,6 +111,8 @@ export { resolveDataFlowWindow, resolveDataFlowMeasure, formatDataFlowValue, + formatDataFlowValueCompact, + fitDataFlowSegmentLabel, } from './dataFlow/dataFlow.utils'; export type { DataFlowBinConfig, 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..dc18337c0 100644 --- a/ui/packages/@quent/utils/src/index.ts +++ b/ui/packages/@quent/utils/src/index.ts @@ -36,6 +36,8 @@ export { formatDurationForWindow, formatDurationForAxisInterval, formatQuantity, + formatQuantityCompact, + formatCompactWithPrefix, formatBytes, formatNumber, formatAttributeValue, diff --git a/ui/src/components/DataFlowOverlay.test.tsx b/ui/src/components/DataFlowOverlay.test.tsx index ea178cd8b..0032528e4 100644 --- a/ui/src/components/DataFlowOverlay.test.tsx +++ b/ui/src/components/DataFlowOverlay.test.tsx @@ -8,7 +8,8 @@ import { useDataFlowSync } from '@quent/hooks'; import { DagPlayhead, NodeFlowBar } from '@quent/components'; import type { DataFlowTimelineResponse, EntityRef, QueryBundle } from '@quent/utils'; -// 4 bins of 2s over [0, 8): op-1 totals per bin are [1, 3, 5, 0]. +// 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: DataFlowTimelineResponse = { Binned: { config: { span: { start: 0, end: 8 }, bin_duration: 2, num_bins: BigInt(4) }, @@ -19,7 +20,10 @@ const RESPONSE: DataFlowTimelineResponse = { { key: 'memory', display_name: 'Memory' }, { key: 'filesystem', display_name: 'Filesystem' }, ], - measures: [{ name: 'tasks', display_name: 'Tasks', quantity: 'unit', kind: 'Occupancy' }], + measures: [ + { name: 'tasks', display_name: 'Tasks', quantity: 'unit', kind: 'Occupancy' }, + { name: 'bytes', display_name: 'Bytes', quantity: 'capacity_bytes', kind: 'Occupancy' }, + ], }, operators: { 'op-1': { @@ -28,6 +32,27 @@ const RESPONSE: DataFlowTimelineResponse = { 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: DataFlowTimelineResponse = { + Binned: { + ...RESPONSE.Binned, + operators: { + ...RESPONSE.Binned.operators, + 'op-2': { + values: { + tasks: { + queueing: { memory: [0, 0, 0, 1000] }, + }, }, }, }, @@ -55,6 +80,13 @@ const QUERY_BUNDLE = { occupancy_prefix: 'None', rate_prefix: 'None', }, + capacity_bytes: { + symbol: 'B', + singular: 'byte', + plural: 'bytes', + occupancy_prefix: 'Iec', + rate_prefix: 'Si', + }, }, } as unknown as QueryBundle; @@ -76,6 +108,10 @@ function renderOverlay(response: DataFlowTimelineResponse) { ); } +function segmentLabels(): string[] { + return screen.queryAllByTestId('flow-segment-label').map(el => el.textContent ?? ''); +} + describe('data-flow overlay components', () => { it('renders nothing when the response is "Unsupported"', () => { renderOverlay('Unsupported'); @@ -91,19 +127,35 @@ describe('data-flow overlay components', () => { expect(slider).toHaveAttribute('aria-valuenow', '0'); }); - it('shows the operator total at the current bin in the flow bar', () => { + it('shows totals for every measure with data at the current bin', () => { renderOverlay(RESPONSE); - // Bin 0: queueing/memory = 1. - expect(screen.getByTestId('node-flow-bar')).toHaveTextContent('1.0'); + // Bin 0: tasks 1, bytes 0 — the zero measure is omitted. + expect(screen.getByTestId('flow-bar-totals').textContent).toBe('1'); }); - it('advances one bin per ArrowRight and updates the flow bar', () => { + 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: queueing 2 + computing 1 = 3. - expect(screen.getByTestId('node-flow-bar')).toHaveTextContent('3.0'); + // 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', () => { @@ -111,9 +163,10 @@ describe('data-flow overlay components', () => { const slider = screen.getByRole('slider'); fireEvent.keyDown(slider, { key: 'End' }); expect(slider).toHaveAttribute('aria-valuenow', '8'); - // Last bin is all-zero for op-1: label collapses to a non-breaking space. + // Last bin is all-zero for op-1: labels collapse to a non-breaking space. const bar = screen.getByTestId('node-flow-bar'); - expect(bar).not.toHaveTextContent('1.0'); - expect(bar.textContent).toContain('\u00A0'); + expect(segmentLabels()).toEqual([]); + expect(screen.getByTestId('flow-bar-totals').textContent).toBe('\u00A0'); + expect(bar).toBeInTheDocument(); }); }); 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'), }, From c8e8ffa9595969606991c9c74866eaa088dd3b37 Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Wed, 15 Jul 2026 12:09:50 -0500 Subject: [PATCH 04/14] fix(ui): pipe separator for node flow totals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '121 · 5GB' reads like the decimal '121.5GB'; '121 | 5GB' does not. Co-Authored-By: Claude Fable 5 --- ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx | 5 +++-- ui/src/components/DataFlowOverlay.test.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx index 6a1ea4c2a..fc380ba19 100644 --- a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx +++ b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx @@ -73,13 +73,14 @@ export const NodeFlowBar = memo( 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 · 45MiB" (count · bytes). + // 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(' · ') + .join(' | ') : ''; return ( diff --git a/ui/src/components/DataFlowOverlay.test.tsx b/ui/src/components/DataFlowOverlay.test.tsx index 0032528e4..1a1aa7100 100644 --- a/ui/src/components/DataFlowOverlay.test.tsx +++ b/ui/src/components/DataFlowOverlay.test.tsx @@ -145,7 +145,7 @@ describe('data-flow overlay components', () => { 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'); + 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']); }); From 783e5781bc42def568a7a94e4468eeea2acaabd6 Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Wed, 15 Jul 2026 12:33:47 -0500 Subject: [PATCH 05/14] feat(ui): data-flow label measure, labeled tier bar, and tier selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additions to the DAG data-flow overlay: - Segment-label measure toggle: a new "Bar labels" select in the DAG controls switches the values INSIDE the state-bar segments between the declared measures (e.g. batch count vs bytes) independently of the measure that sizes the bars (null = follow the bar measure). The frame carries labelByState/labelByDimension for the label measure — aliased to the bar-measure arrays when they coincide, so scrub-tick cost is unchanged. Width-gating still checks the rendered text against the bar-measure segment width. - Labeled memory-tier bar: the dimension/tier bar grows from 3px to the same 12px labeled height as the state bar (2px gap, capacity colors vs FSM colors) and renders each tier's total inside its segment with the same width-gating and compact formatting, using the label measure. - Tier selection: chips in the DAG controls (labeled with the server-declared dimension name) choose which dimension keys are represented. The selection filters state-bar widths/labels, the tier bar, the node totals line, totalsByMeasure, windowMax (recomputed over the selection, stable while scrubbing), and the info-panel matrix columns; the legend greys out deselected tiers. The last selected tier cannot be unchecked, and empty/stale selections resolve to "all"; the selection resets when the declared key set changes (query/engine switch). Co-Authored-By: Claude Fable 5 --- .../@quent/components/src/dag/DAGControls.tsx | 83 ++++++- .../@quent/components/src/dag/DAGLegend.tsx | 50 +++- .../components/src/dag/DAGNodeInfoPanel.tsx | 25 +- .../components/src/query-plan/NodeFlowBar.tsx | 95 +++++-- .../@quent/hooks/src/atoms/dataFlow.ts | 16 ++ .../hooks/src/dataFlow/dataFlow.utils.test.ts | 148 +++++++++++ .../hooks/src/dataFlow/dataFlow.utils.ts | 201 ++++++++++++--- .../hooks/src/dataFlow/dataFlowSelectors.ts | 8 + .../hooks/src/dataFlow/useDataFlowSync.ts | Bin 3836 -> 5830 bytes ui/packages/@quent/hooks/src/index.ts | 6 + ui/src/components/DataFlowOverlay.test.tsx | 234 +++++++++++++++++- 11 files changed, 785 insertions(+), 81 deletions(-) diff --git a/ui/packages/@quent/components/src/dag/DAGControls.tsx b/ui/packages/@quent/components/src/dag/DAGControls.tsx index f225a6dc5..2a5de1e98 100644 --- a/ui/packages/@quent/components/src/dag/DAGControls.tsx +++ b/ui/packages/@quent/components/src/dag/DAGControls.tsx @@ -15,15 +15,29 @@ import { 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, Activity, Gauge } from 'lucide-react'; +import { + Palette, + Spline, + Brush, + Type, + ArrowUpDown, + Activity, + Gauge, + Tags, + Layers, +} from 'lucide-react'; import { PalettePicker } from './PalettePicker'; interface DAGControlsProps { @@ -58,6 +72,9 @@ export const DAGControls = ({ operatorStatFields, portStatFields, isDark }: DAGC 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 })); @@ -70,6 +87,25 @@ export const DAGControls = ({ operatorStatFields, portStatFields, isDark }: DAGC ? 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 (
@@ -157,6 +193,51 @@ export const DAGControls = ({ operatorStatFields, portStatFields, isDark }: DAGC 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 1d53a8fdb..7b510b942 100644 --- a/ui/packages/@quent/components/src/dag/DAGLegend.tsx +++ b/ui/packages/@quent/components/src/dag/DAGLegend.tsx @@ -57,9 +57,14 @@ 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; } -const CategoricalLegend = ({ field, categoryMap }: CategoricalLegendProps) => { +const CategoricalLegend = ({ field, categoryMap, dimmedLabels }: CategoricalLegendProps) => { const entries = [...categoryMap.entries()].slice(0, MAX_CATEGORICAL_ENTRIES); const truncated = categoryMap.size > MAX_CATEGORICAL_ENTRIES; return ( @@ -68,17 +73,26 @@ const CategoricalLegend = ({ field, categoryMap }: CategoricalLegendProps) => { {field}
- {entries.map(([label, color]) => ( -
- - - {label} - -
- ))} + {entries.map(([label, color]) => { + const dimmed = dimmedLabels?.has(label) ?? false; + return ( +
+ + + {label} + +
+ ); + })} {truncated && ( +{categoryMap.size - MAX_CATEGORICAL_ENTRIES} more @@ -179,6 +193,17 @@ export const DAGLegend = ({ isDark }: DAGLegendProps) => { 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 = @@ -212,6 +237,7 @@ export const DAGLegend = ({ isDark }: DAGLegendProps) => { )} diff --git a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx index 7b043e145..ef32e26bc 100644 --- a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx +++ b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx @@ -30,7 +30,9 @@ const ColorDot = ({ color }: { color: string }) => ( /** * 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. + * ("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. */ const DataFlowMatrix = ({ meta, @@ -44,7 +46,16 @@ const DataFlowMatrix = ({ isDark: boolean; }) => { const paletteTheme: PaletteTheme = isDark ? 'dark' : 'light'; - const dimensionKeys = meta.decl.dimension_keys; + 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( () => createFsmTypeColorFn(meta.fsmType ? { [meta.fsmType.name]: meta.fsmType } : {}, paletteTheme), @@ -53,10 +64,10 @@ const DataFlowMatrix = ({ const dimensionColor = useMemo( () => createCapacitiesColorFn( - dimensionKeys.map(k => k.key), + allDimensionKeys.map(k => k.key), paletteTheme ), - [dimensionKeys, paletteTheme] + [allDimensionKeys, paletteTheme] ); const fmt = (value: number) => formatDataFlowValue(value, frame.measure, meta); @@ -77,7 +88,7 @@ const DataFlowMatrix = ({ {meta.decl.dimension_name} - {dimensionKeys.map(k => ( + {dimensionColumns.map(({ key: k }) => ( @@ -97,7 +108,7 @@ const DataFlowMatrix = ({ {state} - {dimensionKeys.map((k, dimensionIndex) => ( + {dimensionColumns.map(({ key: k, index: dimensionIndex }) => ( {fmt(operatorFrame.matrix[stateIndex]?.[dimensionIndex] ?? 0)} @@ -111,7 +122,7 @@ const DataFlowMatrix = ({ ))} Total - {dimensionKeys.map((k, dimensionIndex) => ( + {dimensionColumns.map(({ key: k, index: dimensionIndex }) => ( {fmt(operatorFrame.byDimension[dimensionIndex] ?? 0)} diff --git a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx index fc380ba19..a64cd95e7 100644 --- a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx +++ b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx @@ -31,16 +31,46 @@ function fsmTypesMapOf(fsmType: FsmTypeDecl | null): { [key in string]?: FsmType return fsmType ? { [fsmType.name]: fsmType } : {}; } +/** Width-gated value label centered inside an overflow-hidden segment. */ +const SegmentValueLabel = ({ + label, + segmentColor, + testId, +}: { + label: string; + segmentColor: string; + testId: string; +}) => ( + + {label} + +); + /** - * Per-node data-flow overlay: a stacked state bar (with width-gated - * in-segment value labels) over a thin dimension bar, plus a tiny totals - * label covering every declared measure. 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. + * 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, - * so scrubbing never causes layout churn. Labels are absolutely positioned - * inside overflow-hidden segments, so their appearance never shifts layout. + * 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 }) => { @@ -97,7 +127,11 @@ export const NodeFlowBar = memo( frame.maxTotal, frame.measure, meta, - FLOW_TRACK_PX + FLOW_TRACK_PX, + { + value: operatorFrame.labelByState[stateIndex] ?? 0, + measure: frame.labelMeasure, + } ); return (
{label != null && ( - - {label} - + )}
); })}
-
+
{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 (
+ className="relative overflow-hidden" + style={{ flexGrow: value, backgroundColor: color }} + > + {label != null && ( + + )} +
); })}
diff --git a/ui/packages/@quent/hooks/src/atoms/dataFlow.ts b/ui/packages/@quent/hooks/src/atoms/dataFlow.ts index 33385b4ba..a7582cabd 100644 --- a/ui/packages/@quent/hooks/src/atoms/dataFlow.ts +++ b/ui/packages/@quent/hooks/src/atoms/dataFlow.ts @@ -22,6 +22,22 @@ 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. diff --git a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts index 969dce522..53e2afcef 100644 --- a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts @@ -13,6 +13,8 @@ import { formatDataFlowValueCompact, isDataFlowAvailable, normalizeDataFlowResponse, + resolveDataFlowDimensions, + resolveDataFlowLabelMeasure, resolveDataFlowMeasure, resolveDataFlowStates, resolveDataFlowWindow, @@ -211,6 +213,28 @@ describe('resolveDataFlowStates', () => { }); }); +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]. @@ -224,6 +248,18 @@ describe('computeWindowMax', () => { 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', () => { @@ -302,6 +338,71 @@ describe('extractDataFlowFrame', () => { const frame = extractDataFlowFrame(binned, stateNames, 'tasks', 3, 5); expect(frame.totalsByMeasure.size).toBe(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', () => { @@ -311,6 +412,7 @@ describe('buildDataFlowMeta', () => { 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); }); @@ -319,6 +421,18 @@ describe('buildDataFlowMeta', () => { 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', () => { @@ -338,6 +452,19 @@ describe('resolveDataFlowMeasure', () => { }); }); +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 }); @@ -409,4 +536,25 @@ describe('fitDataFlowSegmentLabel', () => { 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 index 24156e30f..e26bf1886 100644 --- a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts @@ -44,16 +44,27 @@ export interface DataFlowMeta { bin: DataFlowBinConfig; /** * Per-measure max operator total across ALL bins of the window — keeps the - * bar scale stable while scrubbing. + * 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. */ +/** + * 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 dimension keys. */ + /** Sum over all states and selected dimension keys. */ total: number; /** Totals indexed by `DataFlowMeta.stateNames` order. */ byState: number[]; @@ -61,6 +72,18 @@ export interface DataFlowOperatorFrame { 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. */ @@ -68,17 +91,22 @@ 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. */ + /** 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) — 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. + * 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>; } @@ -156,18 +184,46 @@ export function resolveDataFlowStates( } /** - * Max operator total (summed over states and dimension keys) across all bins - * of the window for one measure. Missing entries count as zero. + * 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 computeWindowMax(binned: DataFlowTimelineBinned, measure: string): number { +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 values of Object.values(dims)) { + 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]!; } @@ -179,31 +235,52 @@ export function computeWindowMax(binned: DataFlowTimelineBinned, measure: string 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 (a single cheap pass, recomputed per - * scrub tick). + * for every declared measure at the bin — 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 + 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>(); for (const [operatorId, series] of Object.entries(binned.operators)) { - // Totals at this bin for every declared measure (selected or not). + // Totals at this bin for every declared measure (selected or not), + // summed over the selected dimension keys only. const totals: Record = {}; let hasAnyMeasure = false; for (const measureName of measureNames) { @@ -214,6 +291,7 @@ export function extractDataFlowFrame( const dims = measureStates[state]; if (!dims) continue; for (const dimension of dimensionKeys) { + if (!selected.has(dimension)) continue; measureTotal += dims[dimension]?.[clamped] ?? 0; } } @@ -232,6 +310,7 @@ export function extractDataFlowFrame( 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; @@ -242,29 +321,70 @@ export function extractDataFlowFrame( const byDimension = dimensionKeys.map((_, dimensionIndex) => matrix.reduce((acc, row) => acc + row[dimensionIndex]!, 0) ); - perOperator.set(operatorId, { total, byState, byDimension, matrix }); + + // 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, }; } -/** Build the presentation metadata for one normalized response. */ +/** + * 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 + 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); + windowMax[measure.name] = computeWindowMax(binned, measure.name, dimensionSelection); } return { decl: binned.decl, @@ -272,6 +392,7 @@ export function buildDataFlowMeta( stateNames: resolveDataFlowStates(binned, fsmType), bin: extractBinConfig(binned), windowMax, + dimensionSelection, quantitySpecs: quantitySpecs ?? {}, }; } @@ -288,6 +409,19 @@ export function resolveDataFlowMeasure( 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: DistributionDecl, + 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 @@ -330,26 +464,35 @@ export const DATA_FLOW_LABEL_CHAR_PX = 6; export const DATA_FLOW_LABEL_PAD_PX = 4; /** - * Width-gated label for one state segment of the node flow bar. + * Width-gated label for one segment of the node flow bars. * - * The bar's filled width is `total / maxTotal` of the track and each state - * 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 + * 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 + trackPx: number, + label?: { value: number; measure: string } ): string | null { - if (!(value > 0) || !(maxTotal > 0) || !(trackPx > 0)) return 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 label = formatDataFlowValueCompact(value, measureName, meta); - const requiredPx = label.length * DATA_FLOW_LABEL_CHAR_PX + DATA_FLOW_LABEL_PAD_PX; - return segmentPx >= requiredPx ? label : null; + 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 index 8e66b82a7..12674c4cb 100644 --- a/ui/packages/@quent/hooks/src/dataFlow/dataFlowSelectors.ts +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlowSelectors.ts @@ -8,6 +8,8 @@ import { dataFlowEnabledAtom, playheadTimeSAtom, selectedDataFlowMeasureAtom, + dataFlowLabelMeasureAtom, + dataFlowSelectedDimensionsAtom, dataFlowMetaAtom, dataFlowFrameAtom, } from '../atoms/dataFlow'; @@ -21,5 +23,11 @@ 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 index c93226d91551dd9c5cbdf09a8fca127de5d35e57..b37fcd0efaec6bce9d0d9ef671a8986c0cf80bda 100644 GIT binary patch delta 1957 zcmah~Pj4GV6elUv#7Qfasz^bq^oT^dTdgCA+W7j( ziw>j`_px*Gml{0Ehrw-sV#omsR;XPDr zR(609dJ6rWFh(xJgv-3Pc$Qg-02Lsp5Jeqs0)RA%q~O3X2)zJg!1^emH4M2=zS+ku z8@zz=E=q0D0D%&foVqDJkOSmK?xPnmfj;I=a7C`B>aK;8{Gf*gj* z3+()l#op^hb`}NeQ}XCQY`~z0NJCQ|btz>Ir~Ja|!XtPcp_~RZX~i#zkL4hg$%%>%+xq`6>4Z|57UNP zlSci!DO$(gtWz^LHeg=^dmgSP37zi*+YAh(C8BWl**CxnS7qrj^AT8=g*w&4wY&G< zUvt*p**kbZe>?9U?5-U_C^ecq2ZI?Gd!bFLuV{E(zSSUGh?3PP}@l;Poz! z4#F;HDixTYT)dV6Pejb3@Dud6T@98yCE+7NOu8}aTRmuQLC>YL?S7bq+`94H4I8$% zfu~WVXA9u4^m9Z4>e3{5U*Mn`mIk=!I6d6CCY2y0;Zx&k%hC?*&HSg@Y8H0Dn&wh< z0L`Y+wh2C}gX>TLTHQPV%!%lFsUWZwyjs}8`uhJUTZMMB9Lj1A#@L3(^q>D+dpVoW zRhkAJkEim(NiQ}+bSox2&SMsfTcty87 zqG^tpRA$@fU)BFyDo?%TR%TXTaVsEEZe~=fcf^D_wBQL%bi1ggYpAMc7EnQWoq#UW z3{6#}FCjg^@B)2_%TJeoafxmP&eLp&!_#EPLR%Z@PyYGJ?&|DYDxjz*f4cJh-7?_g zXB^MzzkFky=j3uJxJq{z9H07^mKSapOCsemZC8t>hy@xXe-@2=|MJlaKxvVGd->r% DCs&?` delta 144 zcmV;B0B`@sE&LsjxRJmek?YWt{}2b0;Q?0!O=WaplX?Q;2V-bqZf9k4k_2m$a0C^z zqy!fMvl|Be0h6i=qmy9_I=Aa7<0ARsR; -function Harness({ response }: { response: DataFlowTimelineResponse }) { +interface HarnessProps { + response: DataFlowTimelineResponse; + /** 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, + labelMeasure = null, + selectedDimensions = null, + children, +}: HarnessProps) { useDataFlowSync({ response, queryBundle: QUERY_BUNDLE }); + const setLabelMeasure = useSetDataFlowLabelMeasure(); + const setSelectedDimensions = useSetDataFlowSelectedDimensions(); + useEffect(() => { + setLabelMeasure(labelMeasure); + }, [labelMeasure, setLabelMeasure]); + useEffect(() => { + setSelectedDimensions(selectedDimensions); + }, [selectedDimensions, setSelectedDimensions]); return ( - <> - - - + children ?? ( + <> + + + + ) ); } -function renderOverlay(response: DataFlowTimelineResponse) { +function renderOverlay(props: DataFlowTimelineResponse | HarnessProps) { + const harnessProps: HarnessProps = + typeof props === 'object' && 'response' in props ? props : { response: props }; return render( - + ); } @@ -112,6 +166,17 @@ 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 when the response is "Unsupported"', () => { renderOverlay('Unsupported'); @@ -166,7 +231,158 @@ describe('data-flow overlay components', () => { // 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('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) { + return render( + + + + + + + + ); + } + + 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(); + }); }); From c52f37ff38fe527320c18bed9a2961a42d742cb2 Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Wed, 15 Jul 2026 15:52:52 -0500 Subject: [PATCH 06/14] refactor(query-engine): address PR #393 review feedback (Rust) - Curate the domain UI crate's public surface: data_flow module is private, DataFlowTimelineBinned/DataFlowTimelineResponse re-exported from the crate root (consumers updated). - Simulator data_flow_timeline: declare only dimension keys observed in the requested query's tasks, and grow the no-memory sentinel key until it cannot collide with a real memory resource name. Co-Authored-By: Claude Fable 5 --- domains/query_engine/analyzer/src/ui.rs | 4 +-- domains/query_engine/server/src/ui.rs | 2 +- .../tests/fixed/tests/data_flow.rs | 2 +- domains/query_engine/ui/src/lib.rs | 3 +- examples/simulator/analyzer/src/lib.rs | 34 ++++++++++++------- examples/simulator/server/build.rs | 2 +- 6 files changed, 29 insertions(+), 18 deletions(-) diff --git a/domains/query_engine/analyzer/src/ui.rs b/domains/query_engine/analyzer/src/ui.rs index fb89fb0e0..c3e3fee57 100644 --- a/domains/query_engine/analyzer/src/ui.rs +++ b/domains/query_engine/analyzer/src/ui.rs @@ -128,8 +128,8 @@ pub trait UiAnalyzer { fn data_flow_timeline( &self, _request: DistributionTimelineRequest, - ) -> AnalyzerResult { - Ok(ui::data_flow::DataFlowTimelineResponse::Unsupported) + ) -> AnalyzerResult { + Ok(ui::DataFlowTimelineResponse::Unsupported) } } diff --git a/domains/query_engine/server/src/ui.rs b/domains/query_engine/server/src/ui.rs index 0b527663d..738e0576b 100644 --- a/domains/query_engine/server/src/ui.rs +++ b/domains/query_engine/server/src/ui.rs @@ -285,7 +285,7 @@ async fn data_flow_timeline
( State(state): State>, Path(engine_id): Path, Json(request): Json>, -) -> ServerResult> +) -> ServerResult> where A: UiAnalyzer + Send + Sync + 'static, { diff --git a/domains/query_engine/tests/fixed/tests/data_flow.rs b/domains/query_engine/tests/fixed/tests/data_flow.rs index b940aaf95..bed91ff30 100644 --- a/domains/query_engine/tests/fixed/tests/data_flow.rs +++ b/domains/query_engine/tests/fixed/tests/data_flow.rs @@ -14,7 +14,7 @@ use quent_io::{EventCallback, ExporterOptions}; use quent_query_engine_analyzer::ui::UiAnalyzer; use quent_query_engine_fixed as fixed; use quent_query_engine_ui::QueryFilter; -use quent_query_engine_ui::data_flow::{DataFlowTimelineBinned, DataFlowTimelineResponse}; +use quent_query_engine_ui::{DataFlowTimelineBinned, DataFlowTimelineResponse}; use quent_simulator_analyzer::SimulatorUiAnalyzer; use quent_simulator_instrumentation::{SimulatorContext, test_utils::events_from_recorded}; use quent_ui::timeline::{distribution::DistributionTimelineRequest, request::TimelineConfig}; diff --git a/domains/query_engine/ui/src/lib.rs b/domains/query_engine/ui/src/lib.rs index a1fe37e30..a1642976e 100644 --- a/domains/query_engine/ui/src/lib.rs +++ b/domains/query_engine/ui/src/lib.rs @@ -3,7 +3,8 @@ //! Types shared with the UI. -pub mod data_flow; +mod data_flow; +pub use data_flow::{DataFlowTimelineBinned, DataFlowTimelineResponse}; use quent_analyzer::fsm::FsmTypeDecl; use quent_attributes::{Attribute, Value}; diff --git a/examples/simulator/analyzer/src/lib.rs b/examples/simulator/analyzer/src/lib.rs index d6105a5c8..fd27381c8 100644 --- a/examples/simulator/analyzer/src/lib.rs +++ b/examples/simulator/analyzer/src/lib.rs @@ -9,7 +9,7 @@ use quent_query_engine_analyzer::{ }; use quent_query_engine_ui::{ OperatorFilter, QueryBundle, QueryEntities, QueryFilter, - data_flow::{DataFlowTimelineBinned, DataFlowTimelineResponse}, + DataFlowTimelineBinned, DataFlowTimelineResponse, }; use quent_ui::{ FiniteStateMachine, ResourceGroupNode, ResourceTree, convert_resource_tree, @@ -832,6 +832,16 @@ impl UiAnalyzer for SimulatorUiAnalyzer { .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 = DistributionTimelineBuilder::::new(config); for task in self.model.tasks.values() { let Some(operator_id) = task.operator_id() else { @@ -854,8 +864,9 @@ impl UiAnalyzer for SimulatorUiAnalyzer { .iter() .find(|u| memory_names.contains_key(&u.resource_id)); let dimension = - memory_usage.map_or(DIMENSION_NONE, |u| memory_names[&u.resource_id]); + memory_usage.map_or(none_key.as_str(), |u| memory_names[&u.resource_id]); if want_tasks { + present_dimensions.insert(dimension); builder.try_push( DistributionKey { series: operator_id, @@ -878,6 +889,7 @@ impl UiAnalyzer for SimulatorUiAnalyzer { }) .unwrap_or(0); if bytes > 0 { + present_dimensions.insert(dimension); builder.try_push( DistributionKey { series: operator_id, @@ -912,12 +924,8 @@ impl UiAnalyzer for SimulatorUiAnalyzer { .insert(key.dimension.to_owned(), bins); } - let mut memory_instance_names: Vec<&str> = memory_names - .values() - .copied() - .collect::>() - .into_iter() - .collect(); + 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() @@ -926,10 +934,12 @@ impl UiAnalyzer for SimulatorUiAnalyzer { display_name: name.to_owned(), }) .collect(); - dimension_keys.push(DimensionKeyDecl { - key: DIMENSION_NONE.to_owned(), - display_name: "No data resident".to_owned(), - }); + 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 { diff --git a/examples/simulator/server/build.rs b/examples/simulator/server/build.rs index 80c6f6b7a..88af90776 100644 --- a/examples/simulator/server/build.rs +++ b/examples/simulator/server/build.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use quent_query_engine_ui::data_flow::DataFlowTimelineResponse; +use quent_query_engine_ui::DataFlowTimelineResponse; use quent_query_engine_ui::{OperatorFilter, QueryBundle, QueryFilter}; use quent_simulator_ui::EntityRef; use quent_ui::entities::{request::EntityListRequest, response::EntityListResponse}; From 79a2e86f238acffcf7437238fd2f6115333a8578 Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Wed, 15 Jul 2026 15:58:39 -0500 Subject: [PATCH 07/14] refactor(ui): address PR #393 review feedback - DagPlayhead: stop playback and hide the synced crosshair when the overlay is disabled or bin metadata disappears (the component stays mounted while rendering null, so the play interval kept ticking); covered by a new fake-timer test. - DAGChart: discard stale async layout results with a cancelled flag so interleaved calculateLayout calls cannot overwrite a newer layout. - DAGLegend: merge conditional classes via cn() instead of template string concatenation. - DAGNodeInfoPanel: extract DataFlowMatrix and ColorDot into their own files (one PascalCase component per file). - NodeFlowBar: extract SegmentValueLabel into its own file. DagPlayhead's relative '../lib/timeline.utils' import is kept as-is: the '@' alias maps to the app's src/ only, and relative imports are the package-wide convention in @quent/components. Co-Authored-By: Claude Fable 5 --- .../@quent/components/src/dag/ColorDot.tsx | 7 + .../@quent/components/src/dag/DAGChart.tsx | 8 ++ .../@quent/components/src/dag/DAGLegend.tsx | 8 +- .../components/src/dag/DAGNodeInfoPanel.tsx | 129 +----------------- .../@quent/components/src/dag/DagPlayhead.tsx | 10 ++ .../components/src/dag/DataFlowMatrix.tsx | 128 +++++++++++++++++ .../components/src/query-plan/NodeFlowBar.tsx | 25 +--- .../src/query-plan/SegmentValueLabel.tsx | 27 ++++ ui/src/components/DataFlowOverlay.test.tsx | 80 ++++++++++- 9 files changed, 267 insertions(+), 155 deletions(-) create mode 100644 ui/packages/@quent/components/src/dag/ColorDot.tsx create mode 100644 ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx create mode 100644 ui/packages/@quent/components/src/query-plan/SegmentValueLabel.tsx 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 cf0aec87f..8e57ba05a 100644 --- a/ui/packages/@quent/components/src/dag/DAGChart.tsx +++ b/ui/packages/@quent/components/src/dag/DAGChart.tsx @@ -414,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); @@ -427,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/DAGLegend.tsx b/ui/packages/@quent/components/src/dag/DAGLegend.tsx index 7b510b942..b35708153 100644 --- a/ui/packages/@quent/components/src/dag/DAGLegend.tsx +++ b/ui/packages/@quent/components/src/dag/DAGLegend.tsx @@ -14,6 +14,7 @@ import { useDataFlowMeta, } from '@quent/hooks'; import { + cn, createCapacitiesColorFn, createFsmTypeColorFn, getLegendGradientStops, @@ -79,14 +80,17 @@ const CategoricalLegend = ({ field, categoryMap, dimmedLabels }: CategoricalLege
{label} diff --git a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx index ef32e26bc..408cc73c8 100644 --- a/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx +++ b/ui/packages/@quent/components/src/dag/DAGNodeInfoPanel.tsx @@ -1,141 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { ChevronUp, ChevronDown } from 'lucide-react'; import { useSelectedNodeData, useDataFlowEnabled, useDataFlowMeta, useDataFlowFrame, - formatDataFlowValue, - type DataFlowFrame, - type DataFlowMeta, - type DataFlowOperatorFrame, } from '@quent/hooks'; import { DataText } from '../ui/data-text'; import { thinScrollbarClass } from '../ui/thin-scroll'; -import { - createCapacitiesColorFn, - createFsmTypeColorFn, - formatDuration, - inferFieldFormatter, - type PaletteTheme, -} from '@quent/utils'; - -const ColorDot = ({ color }: { color: string }) => ( - -); - -/** - * 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. - */ -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( - () => - createFsmTypeColorFn(meta.fsmType ? { [meta.fsmType.name]: meta.fsmType } : {}, 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 }) => ( - - ))} - - - -
- {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)} -
-
- ); -}; +import { inferFieldFormatter } from '@quent/utils'; +import { DataFlowMatrix } from './DataFlowMatrix'; export const DAGNodeInfoPanel = ({ isDark = false }: { isDark?: boolean }) => { const selectedNodeData = useSelectedNodeData(); diff --git a/ui/packages/@quent/components/src/dag/DagPlayhead.tsx b/ui/packages/@quent/components/src/dag/DagPlayhead.tsx index c8e1709ab..3c79452f2 100644 --- a/ui/packages/@quent/components/src/dag/DagPlayhead.tsx +++ b/ui/packages/@quent/components/src/dag/DagPlayhead.tsx @@ -154,6 +154,16 @@ export function DagPlayhead({ startTimeUnixNs, className }: DagPlayheadProps) { }); }, [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; 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..9b70351eb --- /dev/null +++ b/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx @@ -0,0 +1,128 @@ +// 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, + createFsmTypeColorFn, + 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( + () => + createFsmTypeColorFn(meta.fsmType ? { [meta.fsmType.name]: meta.fsmType } : {}, 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 }) => ( + + ))} + + + +
+ {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/query-plan/NodeFlowBar.tsx b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx index a64cd95e7..5c878b4f5 100644 --- a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx +++ b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx @@ -5,7 +5,6 @@ import { memo, useMemo } from 'react'; import { createCapacitiesColorFn, createFsmTypeColorFn, - isLightColor, type FsmTypeDecl, type PaletteTheme, } from '@quent/utils'; @@ -16,6 +15,7 @@ import { formatDataFlowValueCompact, } from '@quent/hooks'; import { NODE_LAYOUT_WIDTH } from '../dag/layout'; +import { SegmentValueLabel } from './SegmentValueLabel'; const BAR_TRANSITION = 'width 120ms linear'; @@ -31,29 +31,6 @@ function fsmTypesMapOf(fsmType: FsmTypeDecl | null): { [key in string]?: FsmType return fsmType ? { [fsmType.name]: fsmType } : {}; } -/** Width-gated value label centered inside an overflow-hidden segment. */ -const SegmentValueLabel = ({ - label, - segmentColor, - testId, -}: { - label: string; - segmentColor: string; - testId: string; -}) => ( - - {label} - -); - /** * Per-node data-flow overlay: a stacked state bar over a stacked * dimension/tier bar — both 12px with width-gated in-segment value labels — 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/src/components/DataFlowOverlay.test.tsx b/ui/src/components/DataFlowOverlay.test.tsx index 3b7dfd5da..087b03c98 100644 --- a/ui/src/components/DataFlowOverlay.test.tsx +++ b/ui/src/components/DataFlowOverlay.test.tsx @@ -1,18 +1,26 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from 'vitest'; +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 } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import { useDataFlowSync, + useSetDataFlowEnabled, useSetDataFlowLabelMeasure, useSetDataFlowSelectedDimensions, useSetSelectedNodeData, } from '@quent/hooks'; -import { DagPlayhead, DAGLegend, DAGNodeInfoPanel, NodeFlowBar } from '@quent/components'; +import { + DagPlayhead, + DAGLegend, + DAGNodeInfoPanel, + NodeFlowBar, + registerAxisPointerSync, + unregisterAxisPointerSync, +} from '@quent/components'; import type { DataFlowTimelineResponse, EntityRef, QueryBundle } from '@quent/utils'; // 4 bins of 2s over [0, 8): op-1 task totals per bin are [1, 3, 5, 0] and @@ -120,6 +128,8 @@ const QUERY_BUNDLE = { interface HarnessProps { response: DataFlowTimelineResponse; + /** 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). */ @@ -129,13 +139,18 @@ interface HarnessProps { 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]); @@ -247,6 +262,65 @@ describe('data-flow overlay components', () => { }); }); +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('segment-label measure toggle', () => { it('switches in-segment texts to the label measure without changing widths', () => { const { rerender } = renderOverlay(LABEL_RESPONSE); From 1af48558224c3e7c9da8aa944bb68dced20cc095 Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Thu, 16 Jul 2026 03:50:22 -0500 Subject: [PATCH 08/14] style: rustfmt; a11y headers on the data-flow matrix - cargo fmt over the new distribution/simulator/test files (CI's fmt --check gate). - DataFlowMatrix: semantic scope=col/row headers, state cells as row headers, and a 'State / ' top-left header so screen readers associate each value with both axes (review feedback). Co-Authored-By: Claude Fable 5 --- .../src/timeline/binned/distribution.rs | 11 ++- .../tests/fixed/tests/data_flow.rs | 96 +++++++++++++++++-- examples/simulator/analyzer/src/lib.rs | 4 +- .../components/src/dag/DataFlowMatrix.tsx | 22 +++-- 4 files changed, 109 insertions(+), 24 deletions(-) diff --git a/crates/analyzer/src/timeline/binned/distribution.rs b/crates/analyzer/src/timeline/binned/distribution.rs index 6e428e428..9dcad5201 100644 --- a/crates/analyzer/src/timeline/binned/distribution.rs +++ b/crates/analyzer/src/timeline/binned/distribution.rs @@ -121,7 +121,11 @@ mod tests { let mut builder = DistributionTimelineBuilder::new(test_config()); // Spans [0, 300) and [250, 450) of weight 1 each. - builder.try_push(key(1, "count", "a", "x"), SpanNanoSec::try_new(0, 300).unwrap(), 1.0)?; + builder.try_push( + key(1, "count", "a", "x"), + SpanNanoSec::try_new(0, 300).unwrap(), + 1.0, + )?; builder.try_push( key(1, "count", "a", "x"), SpanNanoSec::try_new(250, 450).unwrap(), @@ -130,10 +134,7 @@ mod tests { let timeline = builder.build(); let bins = timeline.data.get(&key(1, "count", "a", "x")).unwrap(); - assert_eq!( - bins[..], - [1.0, 1.0, 1.5, 1.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0] - ); + assert_eq!(bins[..], [1.0, 1.0, 1.5, 1.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0]); Ok(()) } diff --git a/domains/query_engine/tests/fixed/tests/data_flow.rs b/domains/query_engine/tests/fixed/tests/data_flow.rs index bed91ff30..54049c584 100644 --- a/domains/query_engine/tests/fixed/tests/data_flow.rs +++ b/domains/query_engine/tests/fixed/tests/data_flow.rs @@ -113,21 +113,51 @@ fn distributes_scan_filter_tasks_over_states_and_locations() { // 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()[..], + 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()[..], + 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()[..], + 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()); + assert!( + bins( + &result, + fixed::PHYS_SCAN_FILTER_W0, + "tasks", + "queueing", + "none" + ) + .is_none() + ); } #[test] @@ -137,20 +167,48 @@ fn sending_state_counts_without_memory_location() { // 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()[..], + 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()[..], + 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()[..], + 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()[..], + 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] ); } @@ -169,8 +227,26 @@ fn measures_filter_restricts_response_and_decl() { .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()); + 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] diff --git a/examples/simulator/analyzer/src/lib.rs b/examples/simulator/analyzer/src/lib.rs index fd27381c8..2ce84586d 100644 --- a/examples/simulator/analyzer/src/lib.rs +++ b/examples/simulator/analyzer/src/lib.rs @@ -8,8 +8,8 @@ use quent_query_engine_analyzer::{ ui::{QuentViewer, UiAnalyzer, ViewerEventStream}, }; use quent_query_engine_ui::{ - OperatorFilter, QueryBundle, QueryEntities, QueryFilter, - DataFlowTimelineBinned, DataFlowTimelineResponse, + DataFlowTimelineBinned, DataFlowTimelineResponse, OperatorFilter, QueryBundle, QueryEntities, + QueryFilter, }; use quent_ui::{ FiniteStateMachine, ResourceGroupNode, ResourceTree, convert_resource_tree, diff --git a/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx b/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx index 9b70351eb..4d1ca8ea2 100644 --- a/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx +++ b/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx @@ -75,29 +75,35 @@ export const DataFlowMatrix = ({ - {dimensionColumns.map(({ key: k }) => ( - ))} - + {meta.stateNames.map((state, stateIndex) => ( - {dimensionColumns.map(({ key: k, index: dimensionIndex }) => ( ))} - + {dimensionColumns.map(({ key: k, index: dimensionIndex }) => (
- {meta.decl.dimension_name} + + State / {meta.decl.dimension_name} + {k.display_name} Total + Total +
+ {state} - + @@ -111,7 +117,9 @@ export const DataFlowMatrix = ({
Total + Total + {fmt(operatorFrame.byDimension[dimensionIndex] ?? 0)} From ce70c65bb9d89e90b8c22e250f5a74aa405d9eab Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Thu, 16 Jul 2026 11:31:38 -0500 Subject: [PATCH 09/14] fix(ui): collision-free colors for synthetic data-flow states States the analyzer appends beyond the FSM declaration (e.g. a working- space series) fell back to hash-based colors that could collide with a declared state's palette color (batch_queued vs task_working_space). createDataFlowStateColorFn keeps declared states on their declaration- index colors (consistent with timeline lanes) and assigns appended states the palette slots after the declared block. Co-Authored-By: Claude Fable 5 --- .../@quent/components/src/dag/DAGLegend.tsx | 7 ++-- .../components/src/dag/DataFlowMatrix.tsx | 5 +-- .../components/src/query-plan/NodeFlowBar.tsx | 12 ++---- ui/packages/@quent/utils/src/colors.test.ts | 38 +++++++++++++++++++ ui/packages/@quent/utils/src/colors.ts | 26 +++++++++++++ ui/packages/@quent/utils/src/index.ts | 1 + 6 files changed, 75 insertions(+), 14 deletions(-) diff --git a/ui/packages/@quent/components/src/dag/DAGLegend.tsx b/ui/packages/@quent/components/src/dag/DAGLegend.tsx index b35708153..635ae2c8a 100644 --- a/ui/packages/@quent/components/src/dag/DAGLegend.tsx +++ b/ui/packages/@quent/components/src/dag/DAGLegend.tsx @@ -16,7 +16,7 @@ import { import { cn, createCapacitiesColorFn, - createFsmTypeColorFn, + createDataFlowStateColorFn, getLegendGradientStops, type PaletteTheme, } from '@quent/utils'; @@ -180,8 +180,9 @@ export const DAGLegend = ({ isDark }: DAGLegendProps) => { // and the server-declared dimension keys (colored like capacity series). const dataFlowStateLegend = useMemo(() => { if (!dataFlowMeta) return null; - const colorFn = createFsmTypeColorFn( - dataFlowMeta.fsmType ? { [dataFlowMeta.fsmType.name]: dataFlowMeta.fsmType } : {}, + const colorFn = createDataFlowStateColorFn( + dataFlowMeta.fsmType, + dataFlowMeta.stateNames, paletteTheme ); return new Map(dataFlowMeta.stateNames.map(state => [state, colorFn(state)])); diff --git a/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx b/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx index 4d1ca8ea2..d41ecc153 100644 --- a/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx +++ b/ui/packages/@quent/components/src/dag/DataFlowMatrix.tsx @@ -10,7 +10,7 @@ import { } from '@quent/hooks'; import { createCapacitiesColorFn, - createFsmTypeColorFn, + createDataFlowStateColorFn, formatDuration, type PaletteTheme, } from '@quent/utils'; @@ -47,8 +47,7 @@ export const DataFlowMatrix = ({ [allDimensionKeys, meta.dimensionSelection] ); const stateColor = useMemo( - () => - createFsmTypeColorFn(meta.fsmType ? { [meta.fsmType.name]: meta.fsmType } : {}, paletteTheme), + () => createDataFlowStateColorFn(meta.fsmType, meta.stateNames, paletteTheme), [meta, paletteTheme] ); const dimensionColor = useMemo( diff --git a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx index 5c878b4f5..1d864bfff 100644 --- a/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx +++ b/ui/packages/@quent/components/src/query-plan/NodeFlowBar.tsx @@ -4,8 +4,7 @@ import { memo, useMemo } from 'react'; import { createCapacitiesColorFn, - createFsmTypeColorFn, - type FsmTypeDecl, + createDataFlowStateColorFn, type PaletteTheme, } from '@quent/utils'; import { @@ -27,10 +26,6 @@ const BAR_TRANSITION = 'width 120ms linear'; const FLOW_TRACK_PX = NODE_LAYOUT_WIDTH - 32; /** State colors keyed on the FSM type declaration — matches the timeline view. */ -function fsmTypesMapOf(fsmType: FsmTypeDecl | null): { [key in string]?: FsmTypeDecl } { - return fsmType ? { [fsmType.name]: fsmType } : {}; -} - /** * Per-node data-flow overlay: a stacked state bar over a stacked * dimension/tier bar — both 12px with width-gated in-segment value labels — @@ -56,9 +51,10 @@ export const NodeFlowBar = memo( const theme: PaletteTheme = isDark ? 'dark' : 'light'; const fsmType = meta?.fsmType ?? null; + const stateNames = meta?.stateNames; const stateColor = useMemo( - () => createFsmTypeColorFn(fsmTypesMapOf(fsmType), theme), - [fsmType, theme] + () => createDataFlowStateColorFn(fsmType, stateNames ?? [], theme), + [fsmType, stateNames, theme] ); const dimensionKeys = meta?.decl.dimension_keys; const dimensionColor = useMemo( 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/index.ts b/ui/packages/@quent/utils/src/index.ts index dc18337c0..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, From b250582f00dd3734bac64c531c95a00271e382ed Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Thu, 16 Jul 2026 11:34:59 -0500 Subject: [PATCH 10/14] feat(ui): analyzer-declared default measure for distribution timelines DistributionDecl gains default_measure so the downstream analyzer can pick which measure the UI selects initially (e.g. bytes rather than count); None keeps the first declared measure. Co-Authored-By: Claude Fable 5 --- crates/ui/src/timeline/distribution.rs | 3 +++ examples/simulator/analyzer/src/lib.rs | 1 + examples/simulator/server/ts-bindings/DistributionDecl.ts | 7 ++++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/ui/src/timeline/distribution.rs b/crates/ui/src/timeline/distribution.rs index 68332d994..3f4998aa9 100644 --- a/crates/ui/src/timeline/distribution.rs +++ b/crates/ui/src/timeline/distribution.rs @@ -68,6 +68,9 @@ pub struct DistributionDecl { 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 distribution timeline series: diff --git a/examples/simulator/analyzer/src/lib.rs b/examples/simulator/analyzer/src/lib.rs index 2ce84586d..904f2cb95 100644 --- a/examples/simulator/analyzer/src/lib.rs +++ b/examples/simulator/analyzer/src/lib.rs @@ -966,6 +966,7 @@ impl UiAnalyzer for SimulatorUiAnalyzer { dimension_name: "Data location".to_owned(), dimension_keys, measures, + default_measure: None, }, operators, })) diff --git a/examples/simulator/server/ts-bindings/DistributionDecl.ts b/examples/simulator/server/ts-bindings/DistributionDecl.ts index f0e691892..7b625571e 100644 --- a/examples/simulator/server/ts-bindings/DistributionDecl.ts +++ b/examples/simulator/server/ts-bindings/DistributionDecl.ts @@ -28,4 +28,9 @@ dimension_keys: Array, /** * The measures present in this response. */ -measures: Array, }; +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, }; From c50058f15e1b652dadafafeb68a0c2f81849c46c Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Thu, 16 Jul 2026 11:46:02 -0500 Subject: [PATCH 11/14] feat(ui): honor default_measure; per-tier totals in the DAG legend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveDataFlowMeasure now prefers the analyzer-declared decl.default_measure when the user has no (valid) selection, so the DAG flow bars start on the measure the analyzer picked (Sirius declares "bytes" — bars open on Batch bytes). An explicit valid selection still wins, and an absent/undeclared default keeps the first-declared-measure fallback; the label measure follows the resolved bar measure unchanged. extractDataFlowFrame additionally accumulates dimensionTotalsByMeasure in its existing single pass: global per-tier totals at the bin for every declared measure, summed over ALL operators, states, and dimension keys (deliberately unfiltered by the tier selection). DAGLegend renders each tier of the dimension group with its total at the playhead bin in the current flow measure ("GPU-0 · 12.4GiB"), formatted via the measure's quantity spec; zero totals get no suffix and dimmed (deselected) tiers keep theirs. The tier group lives in a memoized leaf that alone subscribes to the per-scrub frame, so the rest of the legend does not re-render per tick. Co-Authored-By: Claude Fable 5 --- .../@quent/components/src/dag/DAGLegend.tsx | 80 ++++++++++++++++++- .../hooks/src/dataFlow/dataFlow.utils.test.ts | 65 +++++++++++++++ .../hooks/src/dataFlow/dataFlow.utils.ts | 46 ++++++++--- ui/src/components/DataFlowOverlay.test.tsx | 75 ++++++++++++++++- 4 files changed, 250 insertions(+), 16 deletions(-) diff --git a/ui/packages/@quent/components/src/dag/DAGLegend.tsx b/ui/packages/@quent/components/src/dag/DAGLegend.tsx index 635ae2c8a..b70a3d57c 100644 --- a/ui/packages/@quent/components/src/dag/DAGLegend.tsx +++ b/ui/packages/@quent/components/src/dag/DAGLegend.tsx @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { Panel } from '@xyflow/react'; import { useNodeColoringValue, @@ -12,6 +12,9 @@ import { useSelectedEdgeColorField, useDataFlowEnabled, useDataFlowMeta, + useDataFlowFrame, + formatDataFlowValueCompact, + type DataFlowMeta, } from '@quent/hooks'; import { cn, @@ -63,9 +66,21 @@ interface CategoricalLegendProps { * 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, dimmedLabels }: CategoricalLegendProps) => { +const CategoricalLegend = ({ + field, + categoryMap, + dimmedLabels, + entrySuffixes, +}: CategoricalLegendProps) => { const entries = [...categoryMap.entries()].slice(0, MAX_CATEGORICAL_ENTRIES); const truncated = categoryMap.size > MAX_CATEGORICAL_ENTRIES; return ( @@ -76,6 +91,7 @@ const CategoricalLegend = ({ field, categoryMap, dimmedLabels }: CategoricalLege
{entries.map(([label, color]) => { const dimmed = dimmedLabels?.has(label) ?? false; + const suffix = entrySuffixes?.get(label); return (
{label} + {suffix != null && ( + + · {suffix} + + )}
); })} @@ -159,6 +183,54 @@ function EdgeLegendContent({ return ; } +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. + */ +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 ( + + ); +}); + interface DAGLegendProps { /** Whether dark mode is active. Passed explicitly to decouple from ThemeContext. */ isDark: boolean; @@ -239,8 +311,8 @@ export const DAGLegend = ({ isDark }: DAGLegendProps) => { field={dataFlowMeta.decl.entity_type_name} categoryMap={dataFlowStateLegend} /> - diff --git a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts index 53e2afcef..8517aba09 100644 --- a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts @@ -43,6 +43,7 @@ function makeBinned(operators: DataFlowTimelineBinned['operators'] = {}): DataFl { name: 'tasks', display_name: 'Tasks', quantity: 'unit', kind: 'Occupancy' }, { name: 'bytes', display_name: 'Bytes', quantity: 'capacity_bytes', kind: 'Occupancy' }, ], + default_measure: null, }, operators, }; @@ -339,6 +340,47 @@ describe('extractDataFlowFrame', () => { 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')!; @@ -447,8 +489,31 @@ describe('resolveDataFlowMeasure', () => { 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(); }); }); diff --git a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts index e26bf1886..47205c5f9 100644 --- a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts @@ -109,6 +109,15 @@ export interface DataFlowFrame { * 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; } /** @@ -257,9 +266,10 @@ export interface ExtractDataFlowFrameOptions { * `perOperator`. Missing states/dimension keys read as zero. * * Also computes {@link DataFlowFrame.totalsByMeasure} — per-operator totals - * for every declared measure at the bin — and the per-state/per-dimension - * totals of the label measure (a single cheap pass, recomputed per scrub - * tick). + * 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, @@ -277,22 +287,31 @@ export function extractDataFlowFrame( 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), - // summed over the selected dimension keys only. + // 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 (const dimension of dimensionKeys) { - if (!selected.has(dimension)) continue; - measureTotal += dims[dimension]?.[clamped] ?? 0; + 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) { @@ -362,6 +381,7 @@ export function extractDataFlowFrame( maxTotal, perOperator, totalsByMeasure, + dimensionTotalsByMeasure, }; } @@ -399,13 +419,19 @@ export function buildDataFlowMeta( /** * Resolve the effective measure: the selected one when it is declared, - * otherwise the first declared measure (or `null` when none exist). + * 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: DistributionDecl ): string | null { - if (selected != null && decl.measures.some(m => m.name === selected)) return selected; + 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; } diff --git a/ui/src/components/DataFlowOverlay.test.tsx b/ui/src/components/DataFlowOverlay.test.tsx index 087b03c98..98fc1637f 100644 --- a/ui/src/components/DataFlowOverlay.test.tsx +++ b/ui/src/components/DataFlowOverlay.test.tsx @@ -39,6 +39,7 @@ const RESPONSE: DataFlowTimelineResponse = { { 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': { @@ -321,6 +322,25 @@ describe('playback while the overlay is disabled', () => { }); }); +describe('analyzer-declared default measure', () => { + // Same data as RESPONSE, but the analyzer declares bytes as the default. + const BYTES_DEFAULT_RESPONSE: DataFlowTimelineResponse = { + Binned: { + ...RESPONSE.Binned, + decl: { ...RESPONSE.Binned.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); @@ -436,10 +456,14 @@ describe('DAGNodeInfoPanel matrix under tier selection', () => { }); describe('DAGLegend under tier selection', () => { - function renderLegend(selectedDimensions: ReadonlySet | null) { + function renderLegend( + selectedDimensions: ReadonlySet | null, + response: DataFlowTimelineResponse = RESPONSE + ) { return render( - + + @@ -448,6 +472,12 @@ describe('DAGLegend under tier selection', () => { ); } + /** 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(); @@ -459,4 +489,45 @@ describe('DAGLegend under tier selection', () => { 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, { + Binned: { + ...RESPONSE.Binned, + decl: { ...RESPONSE.Binned.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(); + }); }); From 2d0438b77b5f5844dcf53d45b011978099942126 Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Fri, 17 Jul 2026 12:15:51 -0500 Subject: [PATCH 12/14] refactor(analyzer,ui): address maintainer review on the data-flow protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename the 'distribution' aggregation to 'categorical' (values are absolute time-weighted quantities, not a probability distribution): CategoricalKey/Timeline/Builder, CategoricalTimelineRequest/Decl/ Series, regenerated ts-bindings. - CategoricalKey is generic over (Eq + Hash only) — no forced stringification. - Replace the response-level Unsupported variant with AnalyzerError::Unsupported, mapped to HTTP 501 by the server; the data-flow endpoint now returns DataFlowTimelineBinned directly and the trait default errs Unsupported. - Reject any unknown requested measure, even alongside valid ones (simulator reference impl + test). - Move span-weighting/zero-span/out-of-window tests down to the keyed aggregator where that behavior lives; the categorical layer keeps key-identity and non-string-key tests. - Drop the UI-protocol section from the domain event-model doc (tracked separately for future analyzer-protocol docs). Co-Authored-By: Claude Fable 5 --- crates/analyzer/src/error.rs | 2 + .../{distribution.rs => categorical.rs} | 122 +++++++----------- crates/analyzer/src/timeline/binned/mod.rs | 48 ++++++- .../{distribution.rs => categorical.rs} | 29 +++-- crates/ui/src/timeline/mod.rs | 2 +- docs/domains/query_engine/README.md | 24 ---- domains/query_engine/analyzer/src/ui.rs | 23 ++-- domains/query_engine/server/src/error.rs | 6 + domains/query_engine/server/src/ui.rs | 8 +- .../tests/fixed/tests/data_flow.rs | 29 ++--- domains/query_engine/ui/src/data_flow.rs | 25 ++-- domains/query_engine/ui/src/lib.rs | 2 +- examples/simulator/analyzer/src/lib.rs | 46 ++++--- examples/simulator/server/build.rs | 8 +- ...DistributionDecl.ts => CategoricalDecl.ts} | 6 +- ...ributionSeries.ts => CategoricalSeries.ts} | 4 +- ...quest.ts => CategoricalTimelineRequest.ts} | 4 +- .../ts-bindings/DataFlowTimelineBinned.ts | 16 ++- .../ts-bindings/DataFlowTimelineResponse.ts | 7 - .../server/ts-bindings/DimensionKeyDecl.ts | 4 +- .../server/ts-bindings/MeasureDecl.ts | 4 +- 21 files changed, 209 insertions(+), 210 deletions(-) rename crates/analyzer/src/timeline/binned/{distribution.rs => categorical.rs} (51%) rename crates/ui/src/timeline/{distribution.rs => categorical.rs} (74%) rename examples/simulator/server/ts-bindings/{DistributionDecl.ts => CategoricalDecl.ts} (86%) rename examples/simulator/server/ts-bindings/{DistributionSeries.ts => CategoricalSeries.ts} (57%) rename examples/simulator/server/ts-bindings/{DistributionTimelineRequest.ts => CategoricalTimelineRequest.ts} (82%) delete mode 100644 examples/simulator/server/ts-bindings/DataFlowTimelineResponse.ts 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/distribution.rs b/crates/analyzer/src/timeline/binned/categorical.rs similarity index 51% rename from crates/analyzer/src/timeline/binned/distribution.rs rename to crates/analyzer/src/timeline/binned/categorical.rs index 9dcad5201..ab9a19a1e 100644 --- a/crates/analyzer/src/timeline/binned/distribution.rs +++ b/crates/analyzer/src/timeline/binned/categorical.rs @@ -1,18 +1,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Binned timelines of weighted distributions over (state, dimension) pairs. +//! Binned timelines of weighted values keyed by categories. //! -//! A distribution timeline describes, per opaque series (e.g. an operator in a +//! 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) is distributed over the states of a finite state machine and an +//! 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. +//! 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 all opaque to the aggregation. Downstream analyzers -//! decide what they mean and are expected to keep dimension keys a small -//! enumerable set. +//! 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; @@ -25,39 +26,42 @@ use crate::{ timeline::binned::{BinnedTimelineAggregator, KeyedAggregator}, }; -/// Identity of one aggregation cell of a distribution timeline. +/// Identity of one aggregation cell of a categorical timeline. #[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct DistributionKey<'a, S> { +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: &'a str, + pub measure: M, /// The FSM state name during the span. - pub state: &'a str, + pub state: St, /// Application-defined dimension key (opaque to the aggregation). - pub dimension: &'a str, + pub dimension: D, } -/// A binned timeline of weighted (state, dimension) distributions for -/// multiple series and measures. +/// A binned timeline of weighted (state, dimension) values for multiple +/// series and measures. #[derive(Clone, Debug)] -pub struct DistributionTimeline<'a, S> { +pub struct CategoricalTimeline { pub config: BinnedSpan, - pub data: HashMap, Vec>, + pub data: HashMap, Vec>, } -/// Builds a [`DistributionTimeline`] from weighted samples. +/// 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 DistributionTimelineBuilder<'a, S> { - aggregator: KeyedAggregator>, +pub struct CategoricalTimelineBuilder { + aggregator: KeyedAggregator>, } -impl<'a, S> DistributionTimelineBuilder<'a, S> +impl CategoricalTimelineBuilder where - S: Eq + Hash + Clone, + S: Eq + Hash, + M: Eq + Hash, + St: Eq + Hash, + D: Eq + Hash, { pub fn new(config: BinnedSpan) -> Self { Self { @@ -73,15 +77,15 @@ where /// Attempt to push one weighted sample spanning `span` into the timeline. pub fn try_push( &mut self, - key: DistributionKey<'a, S>, + key: CategoricalKey, span: SpanNanoSec, weight: f64, ) -> AnalyzerResult<()> { self.aggregator.try_push(span, (key, weight)) } - pub fn build(self) -> DistributionTimeline<'a, S> { - DistributionTimeline { + pub fn build(self) -> CategoricalTimeline { + CategoricalTimeline { config: self.aggregator.config(), data: self.aggregator.finish(), } @@ -107,8 +111,8 @@ mod tests { measure: &'a str, state: &'a str, dimension: &'a str, - ) -> DistributionKey<'a, u32> { - DistributionKey { + ) -> CategoricalKey { + CategoricalKey { series, measure, state, @@ -116,31 +120,11 @@ mod tests { } } - #[test] - fn span_weighting_across_bin_boundaries() -> AnalyzerResult<()> { - let mut builder = DistributionTimelineBuilder::new(test_config()); - - // Spans [0, 300) and [250, 450) of weight 1 each. - builder.try_push( - key(1, "count", "a", "x"), - SpanNanoSec::try_new(0, 300).unwrap(), - 1.0, - )?; - builder.try_push( - key(1, "count", "a", "x"), - SpanNanoSec::try_new(250, 450).unwrap(), - 1.0, - )?; - - let timeline = builder.build(); - let bins = timeline.data.get(&key(1, "count", "a", "x")).unwrap(); - assert_eq!(bins[..], [1.0, 1.0, 1.5, 1.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0]); - Ok(()) - } - + /// 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 = DistributionTimelineBuilder::new(test_config()); + 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)?; @@ -162,34 +146,26 @@ mod tests { Ok(()) } + /// Non-string key components only need `Eq + Hash` — no stringification. #[test] - fn zero_duration_span_is_noop() -> AnalyzerResult<()> { - let mut builder = DistributionTimelineBuilder::new(test_config()); - builder.try_push( - key(1, "count", "a", "x"), - SpanNanoSec::try_new(500, 500).unwrap(), - 1.0, - )?; - - let timeline = builder.build(); - // The key exists (aggregator was created) but all bins remain zero. - let bins = timeline.data.get(&key(1, "count", "a", "x")).unwrap(); - assert_eq!(bins[..], [0.0; 10]); - Ok(()) - } + fn non_string_key_components() -> AnalyzerResult<()> { + #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] + enum Measure { + Count, + } - #[test] - fn out_of_window_span_contributes_nothing() -> AnalyzerResult<()> { - let mut builder = DistributionTimelineBuilder::new(test_config()); - builder.try_push( - key(1, "count", "a", "x"), - SpanNanoSec::try_new(2000, 3000).unwrap(), - 1.0, - )?; + 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(); - let bins = timeline.data.get(&key(1, "count", "a", "x")).unwrap(); - assert_eq!(bins[..], [0.0; 10]); + 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 8820f7220..f6380ec8e 100644 --- a/crates/analyzer/src/timeline/binned/mod.rs +++ b/crates/analyzer/src/timeline/binned/mod.rs @@ -9,7 +9,7 @@ use quent_time::{SpanNanoSec, bin::BinnedSpan}; use crate::AnalyzerResult; -pub mod distribution; +pub mod categorical; pub mod resource; /// A trait for types that can aggregate items into a sequence of time bins. @@ -147,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/distribution.rs b/crates/ui/src/timeline/categorical.rs similarity index 74% rename from crates/ui/src/timeline/distribution.rs rename to crates/ui/src/timeline/categorical.rs index 3f4998aa9..7cfabf00d 100644 --- a/crates/ui/src/timeline/distribution.rs +++ b/crates/ui/src/timeline/categorical.rs @@ -1,9 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Requests and responses for distribution timelines: binned timelines of a -//! weighted distribution over (FSM state, application-defined dimension) -//! pairs, for one or more application-declared measures. +//! 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 @@ -16,9 +17,9 @@ use ts_rs::TS; use crate::{quantity::CapacityKind, timeline::request::TimelineConfig}; -/// Request for a distribution timeline. +/// Request for a categorical timeline. #[derive(TS, Debug, Clone, Serialize, Deserialize)] -pub struct DistributionTimelineRequest { +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. @@ -27,11 +28,11 @@ pub struct DistributionTimelineRequest { pub app_params: GlobalParams, } -/// A measure declared by the downstream analyzer for a distribution timeline, +/// 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 [`DistributionSeries::values`]. + /// Unique name; key into [`CategoricalSeries::values`]. pub name: String, /// Human-friendly display name. pub display_name: String, @@ -41,23 +42,23 @@ pub struct MeasureDecl { pub kind: CapacityKind, } -/// One key of the application-defined dimension of a distribution timeline. +/// One key of the application-defined dimension of a categorical timeline. #[derive(TS, Debug, Clone, Serialize)] pub struct DimensionKeyDecl { - /// The key used in [`DistributionSeries::values`]. + /// The key used in [`CategoricalSeries::values`]. pub key: String, /// Human-friendly display name. pub display_name: String, } -/// Presentation metadata for a distribution timeline, declared by the +/// 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 DistributionDecl { - /// The FSM type whose states are distributed. References an entry in the +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, @@ -73,11 +74,11 @@ pub struct DistributionDecl { pub default_measure: Option, } -/// Binned values of one distribution timeline series: +/// 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 DistributionSeries { +pub struct CategoricalSeries { pub values: HashMap>>>, } diff --git a/crates/ui/src/timeline/mod.rs b/crates/ui/src/timeline/mod.rs index f1b6fbbcd..5f7a50cc6 100644 --- a/crates/ui/src/timeline/mod.rs +++ b/crates/ui/src/timeline/mod.rs @@ -2,6 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 //! Requests and responses for timelines. -pub mod distribution; +pub mod categorical; pub mod request; pub mod response; diff --git a/docs/domains/query_engine/README.md b/docs/domains/query_engine/README.md index f97026d60..dde8c5a5b 100644 --- a/docs/domains/query_engine/README.md +++ b/docs/domains/query_engine/README.md @@ -189,30 +189,6 @@ act as [Resource Groups][resource-group], forming a hierarchy through which resource usages can be aggregated. See [Resource Group][resource-group] for details. -## Data-flow distribution timeline - -The analyzer trait offers an optional `data_flow_timeline` method (HTTP: -`POST /api/engines/{engine_id}/timeline/data-flow`) powering the UI's -data-flow-over-time view of a query plan: for every [Operator][operator] of a -query, a binned timeline of a distribution over -(FSM state × application-defined dimension), for one or more -application-declared measures. - -Consistent with Operators having no FSM (see [Operator][operator] notes), all -semantics live in the application's analyzer: - -- **Entity**: which FSM type is distributed (e.g. a task or batch entity that - works on behalf of an Operator), referenced by `entity_type_name` into the - query bundle's FSM type declarations for state names and ordering. -- **Dimension**: an opaque, small, enumerable key set declared per response - (e.g. where an entity's data resides), with display names and stable order. -- **Measures**: named weights (e.g. an entity count, resident bytes) with a - quantity spec reference for unit formatting. - -Bin values are span-weighted (an entity in a state for a fraction of a bin -contributes that fraction), matching all other timelines. Analyzers that do -not provide the feature return `Unsupported` — the default implementation — -and the UI hides the view. [mutual-exclusion]: ../../modeling/README.md#mutual-exclusion [engine]: #engine diff --git a/domains/query_engine/analyzer/src/ui.rs b/domains/query_engine/analyzer/src/ui.rs index c3e3fee57..d2a666e37 100644 --- a/domains/query_engine/analyzer/src/ui.rs +++ b/domains/query_engine/analyzer/src/ui.rs @@ -4,14 +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::{ - distribution::DistributionTimelineRequest, + categorical::CategoricalTimelineRequest, request::{BulkChunkedTimelineRequest, BulkTimelineRequest, SingleTimelineRequest}, response::{ BulkChunkedTimelinesResponse, BulkTimelinesResponse, BulkTimelinesResponseEntry, @@ -118,18 +118,19 @@ pub trait UiAnalyzer { Ok(BulkChunkedTimelinesResponse { entries }) } - /// Return, for every operator of a query, a binned timeline of a - /// distribution 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. + /// 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 reports the feature as unsupported, so - /// existing analyzers keep compiling and the UI hides the view. + /// 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: DistributionTimelineRequest, - ) -> AnalyzerResult { - Ok(ui::DataFlowTimelineResponse::Unsupported) + _request: CategoricalTimelineRequest, + ) -> AnalyzerResult { + Err(AnalyzerError::Unsupported) } } 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 738e0576b..437294d7f 100644 --- a/domains/query_engine/server/src/ui.rs +++ b/domains/query_engine/server/src/ui.rs @@ -12,7 +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::{ - distribution::DistributionTimelineRequest, + categorical::CategoricalTimelineRequest, request::{BulkTimelineRequest, SingleTimelineRequest}, response::{BulkTimelinesResponse, SingleTimelineResponse}, }; @@ -277,15 +277,15 @@ where ), request_body = Object, responses( - (status = 200, description = "Per-operator distribution timeline, or Unsupported", body = Object) + (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> + Json(request): Json>, +) -> ServerResult> where A: UiAnalyzer + Send + Sync + 'static, { diff --git a/domains/query_engine/tests/fixed/tests/data_flow.rs b/domains/query_engine/tests/fixed/tests/data_flow.rs index 54049c584..03f3c3910 100644 --- a/domains/query_engine/tests/fixed/tests/data_flow.rs +++ b/domains/query_engine/tests/fixed/tests/data_flow.rs @@ -13,11 +13,11 @@ 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_query_engine_ui::{DataFlowTimelineBinned, DataFlowTimelineResponse}; use quent_simulator_analyzer::SimulatorUiAnalyzer; use quent_simulator_instrumentation::{SimulatorContext, test_utils::events_from_recorded}; -use quent_ui::timeline::{distribution::DistributionTimelineRequest, request::TimelineConfig}; +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 @@ -39,8 +39,8 @@ fn fixed_analyzer() -> SimulatorUiAnalyzer { } /// A whole-query request: 7 one-second bins over the 0–7s window. -fn request(measures: &[&str]) -> DistributionTimelineRequest { - DistributionTimelineRequest { +fn request(measures: &[&str]) -> CategoricalTimelineRequest { + CategoricalTimelineRequest { measures: measures.iter().map(|m| m.to_string()).collect(), config: TimelineConfig { num_bins: 7, @@ -53,13 +53,6 @@ fn request(measures: &[&str]) -> DistributionTimelineRequest { } } -fn binned(response: DataFlowTimelineResponse) -> DataFlowTimelineBinned { - match response { - DataFlowTimelineResponse::Binned(binned) => binned, - DataFlowTimelineResponse::Unsupported => panic!("expected Binned, got Unsupported"), - } -} - fn bins<'a>( binned: &'a DataFlowTimelineBinned, operator: uuid::Uuid, @@ -79,7 +72,7 @@ fn bins<'a>( #[test] fn declares_states_dimensions_and_measures() { let analyzer = fixed_analyzer(); - let result = binned(analyzer.data_flow_timeline(request(&[])).unwrap()); + let result = analyzer.data_flow_timeline(request(&[])).unwrap(); assert_eq!(result.decl.entity_type_name, "task"); assert_eq!(result.decl.dimension_name, "Data location"); @@ -109,7 +102,7 @@ fn declares_states_dimensions_and_measures() { #[test] fn distributes_scan_filter_tasks_over_states_and_locations() { let analyzer = fixed_analyzer(); - let result = binned(analyzer.data_flow_timeline(request(&[])).unwrap()); + let result = analyzer.data_flow_timeline(request(&[])).unwrap(); // Two tasks allocating (no memory) for 0.25s each within bin 1. assert_eq!( @@ -163,7 +156,7 @@ fn distributes_scan_filter_tasks_over_states_and_locations() { #[test] fn sending_state_counts_without_memory_location() { let analyzer = fixed_analyzer(); - let result = binned(analyzer.data_flow_timeline(request(&[])).unwrap()); + 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!( @@ -216,7 +209,7 @@ fn sending_state_counts_without_memory_location() { #[test] fn measures_filter_restricts_response_and_decl() { let analyzer = fixed_analyzer(); - let result = binned(analyzer.data_flow_timeline(request(&["tasks"])).unwrap()); + let result = analyzer.data_flow_timeline(request(&["tasks"])).unwrap(); assert_eq!( result @@ -253,4 +246,10 @@ fn measures_filter_restricts_response_and_decl() { 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 index 18e5ae5f7..bc505f409 100644 --- a/domains/query_engine/ui/src/data_flow.rs +++ b/domains/query_engine/ui/src/data_flow.rs @@ -1,18 +1,20 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Types for the per-operator data-flow distribution timeline. +//! Types for the per-operator data-flow categorical timeline. use std::collections::HashMap; use quent_time::bin::BinnedSpanSec; -use quent_ui::timeline::distribution::{DistributionDecl, DistributionSeries}; +use quent_ui::timeline::categorical::{CategoricalDecl, CategoricalSeries}; use serde::Serialize; use ts_rs::TS; use uuid::Uuid; -/// A binned data-flow distribution timeline covering every operator of a -/// query. +/// 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. @@ -21,16 +23,7 @@ pub struct DataFlowTimelineBinned { /// bounds are not exceeded and bin sizes are equal. pub config: BinnedSpanSec, /// Presentation metadata declared by the analyzer. - pub decl: DistributionDecl, - /// Distribution series keyed by operator id. - pub operators: HashMap, -} - -/// Response for a data-flow distribution timeline request. -#[derive(TS, Debug, Clone, Serialize)] -pub enum DataFlowTimelineResponse { - /// This analyzer does not provide data-flow distributions; the UI hides - /// the corresponding view. - Unsupported, - Binned(DataFlowTimelineBinned), + 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 a1642976e..9143b4b91 100644 --- a/domains/query_engine/ui/src/lib.rs +++ b/domains/query_engine/ui/src/lib.rs @@ -4,7 +4,7 @@ //! Types shared with the UI. mod data_flow; -pub use data_flow::{DataFlowTimelineBinned, DataFlowTimelineResponse}; +pub use data_flow::DataFlowTimelineBinned; use quent_analyzer::fsm::FsmTypeDecl; use quent_attributes::{Attribute, Value}; diff --git a/examples/simulator/analyzer/src/lib.rs b/examples/simulator/analyzer/src/lib.rs index 904f2cb95..8fd872784 100644 --- a/examples/simulator/analyzer/src/lib.rs +++ b/examples/simulator/analyzer/src/lib.rs @@ -8,15 +8,14 @@ use quent_query_engine_analyzer::{ ui::{QuentViewer, UiAnalyzer, ViewerEventStream}, }; use quent_query_engine_ui::{ - DataFlowTimelineBinned, DataFlowTimelineResponse, OperatorFilter, QueryBundle, QueryEntities, - QueryFilter, + DataFlowTimelineBinned, OperatorFilter, QueryBundle, QueryEntities, QueryFilter, }; use quent_ui::{ FiniteStateMachine, ResourceGroupNode, ResourceTree, convert_resource_tree, quantity::{CapacityKind, QuantitySpec}, timeline::{ - distribution::{ - DimensionKeyDecl, DistributionDecl, DistributionSeries, DistributionTimelineRequest, + categorical::{ + CategoricalDecl, CategoricalSeries, CategoricalTimelineRequest, DimensionKeyDecl, MeasureDecl, }, request::{ @@ -42,7 +41,7 @@ use quent_analyzer::{ ResourceTypeDecl, Usage, Using, collection::ResourceCollection, tree::ResourceTreeNode, }, timeline::binned::{ - distribution::{DistributionKey, DistributionTimelineBuilder}, + categorical::{CategoricalKey, CategoricalTimelineBuilder}, resource::{ ResourceTimeline, ResourceTimelineBuilder, ResourceTimelineByKey, ResourceTimelineByKeyBuilder, @@ -796,23 +795,28 @@ impl UiAnalyzer for SimulatorUiAnalyzer { fn data_flow_timeline( &self, - request: DistributionTimelineRequest, - ) -> AnalyzerResult { + 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. + // 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); - if !want_tasks && !want_bytes { - return Err(AnalyzerError::InvalidArgument(format!( - "unknown measures {:?}; declared measures are '{MEASURE_TASKS}' and '{MEASURE_BYTES}'", - request.measures - ))); - } let query_operators: HashSet = self .model @@ -842,7 +846,7 @@ impl UiAnalyzer for SimulatorUiAnalyzer { // advertises only these (not every memory in the engine model). let mut present_dimensions: HashSet<&str> = HashSet::default(); - let mut builder = DistributionTimelineBuilder::::new(config); + let mut builder = CategoricalTimelineBuilder::new(config); for task in self.model.tasks.values() { let Some(operator_id) = task.operator_id() else { continue; @@ -868,7 +872,7 @@ impl UiAnalyzer for SimulatorUiAnalyzer { if want_tasks { present_dimensions.insert(dimension); builder.try_push( - DistributionKey { + CategoricalKey { series: operator_id, measure: MEASURE_TASKS, state, @@ -891,7 +895,7 @@ impl UiAnalyzer for SimulatorUiAnalyzer { if bytes > 0 { present_dimensions.insert(dimension); builder.try_push( - DistributionKey { + CategoricalKey { series: operator_id, measure: MEASURE_BYTES, state, @@ -908,7 +912,7 @@ impl UiAnalyzer for SimulatorUiAnalyzer { // 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(); + let mut operators: StdHashMap = StdHashMap::new(); for (key, bins) in builder.build().data { if bins.iter().all(|v| *v == 0.0) { continue; @@ -959,9 +963,9 @@ impl UiAnalyzer for SimulatorUiAnalyzer { }); } - Ok(DataFlowTimelineResponse::Binned(DataFlowTimelineBinned { + Ok(DataFlowTimelineBinned { config: config.try_to_secs_relative(epoch)?, - decl: DistributionDecl { + decl: CategoricalDecl { entity_type_name: Task::fsm_type_declaration().name, dimension_name: "Data location".to_owned(), dimension_keys, @@ -969,7 +973,7 @@ impl UiAnalyzer for SimulatorUiAnalyzer { default_measure: None, }, operators, - })) + }) } } diff --git a/examples/simulator/server/build.rs b/examples/simulator/server/build.rs index 88af90776..5f643a091 100644 --- a/examples/simulator/server/build.rs +++ b/examples/simulator/server/build.rs @@ -1,12 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use quent_query_engine_ui::DataFlowTimelineResponse; +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::{ - distribution::DistributionTimelineRequest, + categorical::CategoricalTimelineRequest, request::{BulkTimelineRequest, SingleTimelineRequest}, response::{BulkTimelinesResponse, SingleTimelineResponse}, }; @@ -24,8 +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)?; as TS>::export_all(&cfg)?; ::export_all(&cfg)?; diff --git a/examples/simulator/server/ts-bindings/DistributionDecl.ts b/examples/simulator/server/ts-bindings/CategoricalDecl.ts similarity index 86% rename from examples/simulator/server/ts-bindings/DistributionDecl.ts rename to examples/simulator/server/ts-bindings/CategoricalDecl.ts index 7b625571e..bc2ab9efe 100644 --- a/examples/simulator/server/ts-bindings/DistributionDecl.ts +++ b/examples/simulator/server/ts-bindings/CategoricalDecl.ts @@ -3,15 +3,15 @@ import type { DimensionKeyDecl } from "./DimensionKeyDecl"; import type { MeasureDecl } from "./MeasureDecl"; /** - * Presentation metadata for a distribution timeline, declared by the + * 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 DistributionDecl = { +export type CategoricalDecl = { /** - * The FSM type whose states are distributed. References an entry in the + * 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. */ diff --git a/examples/simulator/server/ts-bindings/DistributionSeries.ts b/examples/simulator/server/ts-bindings/CategoricalSeries.ts similarity index 57% rename from examples/simulator/server/ts-bindings/DistributionSeries.ts rename to examples/simulator/server/ts-bindings/CategoricalSeries.ts index b96f1b5fa..7aeb19dd0 100644 --- a/examples/simulator/server/ts-bindings/DistributionSeries.ts +++ b/examples/simulator/server/ts-bindings/CategoricalSeries.ts @@ -1,9 +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 distribution timeline series: + * 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 DistributionSeries = { values: { [key in string]: { [key in string]: { [key in string]: Array } } }, }; +export type CategoricalSeries = { values: { [key in string]: { [key in string]: { [key in string]: Array } } }, }; diff --git a/examples/simulator/server/ts-bindings/DistributionTimelineRequest.ts b/examples/simulator/server/ts-bindings/CategoricalTimelineRequest.ts similarity index 82% rename from examples/simulator/server/ts-bindings/DistributionTimelineRequest.ts rename to examples/simulator/server/ts-bindings/CategoricalTimelineRequest.ts index 41863d522..5a331c4ad 100644 --- a/examples/simulator/server/ts-bindings/DistributionTimelineRequest.ts +++ b/examples/simulator/server/ts-bindings/CategoricalTimelineRequest.ts @@ -2,9 +2,9 @@ import type { TimelineConfig } from "./TimelineConfig"; /** - * Request for a distribution timeline. + * Request for a categorical timeline. */ -export type DistributionTimelineRequest = { +export type CategoricalTimelineRequest = { /** * Names of the measures to compute. Empty means all declared measures. */ diff --git a/examples/simulator/server/ts-bindings/DataFlowTimelineBinned.ts b/examples/simulator/server/ts-bindings/DataFlowTimelineBinned.ts index d94b51a98..34306abb0 100644 --- a/examples/simulator/server/ts-bindings/DataFlowTimelineBinned.ts +++ b/examples/simulator/server/ts-bindings/DataFlowTimelineBinned.ts @@ -1,11 +1,13 @@ // 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 { DistributionDecl } from "./DistributionDecl"; -import type { DistributionSeries } from "./DistributionSeries"; +import type { CategoricalDecl } from "./CategoricalDecl"; +import type { CategoricalSeries } from "./CategoricalSeries"; /** - * A binned data-flow distribution timeline covering every operator of a - * query. + * 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 = { /** @@ -18,8 +20,8 @@ config: BinnedSpanSec, /** * Presentation metadata declared by the analyzer. */ -decl: DistributionDecl, +decl: CategoricalDecl, /** - * Distribution series keyed by operator id. + * Categorical series keyed by operator id. */ -operators: { [key in string]: DistributionSeries }, }; +operators: { [key in string]: CategoricalSeries }, }; diff --git a/examples/simulator/server/ts-bindings/DataFlowTimelineResponse.ts b/examples/simulator/server/ts-bindings/DataFlowTimelineResponse.ts deleted file mode 100644 index c1a6d1b4c..000000000 --- a/examples/simulator/server/ts-bindings/DataFlowTimelineResponse.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DataFlowTimelineBinned } from "./DataFlowTimelineBinned"; - -/** - * Response for a data-flow distribution timeline request. - */ -export type DataFlowTimelineResponse = "Unsupported" | { "Binned": DataFlowTimelineBinned }; diff --git a/examples/simulator/server/ts-bindings/DimensionKeyDecl.ts b/examples/simulator/server/ts-bindings/DimensionKeyDecl.ts index 7e7801b7e..e52530b68 100644 --- a/examples/simulator/server/ts-bindings/DimensionKeyDecl.ts +++ b/examples/simulator/server/ts-bindings/DimensionKeyDecl.ts @@ -1,11 +1,11 @@ // 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 distribution timeline. + * One key of the application-defined dimension of a categorical timeline. */ export type DimensionKeyDecl = { /** - * The key used in [`DistributionSeries::values`]. + * The key used in [`CategoricalSeries::values`]. */ key: string, /** diff --git a/examples/simulator/server/ts-bindings/MeasureDecl.ts b/examples/simulator/server/ts-bindings/MeasureDecl.ts index f28dac5c4..7bf5cd947 100644 --- a/examples/simulator/server/ts-bindings/MeasureDecl.ts +++ b/examples/simulator/server/ts-bindings/MeasureDecl.ts @@ -2,12 +2,12 @@ import type { CapacityKind } from "./CapacityKind"; /** - * A measure declared by the downstream analyzer for a distribution timeline, + * 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 [`DistributionSeries::values`]. + * Unique name; key into [`CategoricalSeries::values`]. */ name: string, /** From 85969a3b878e491cf569de33c4b3d2a9b3c5efea Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Fri, 17 Jul 2026 12:26:26 -0500 Subject: [PATCH 13/14] refactor(ui): adapt to the categorical-timeline protocol rename Follow-up to the Rust-side maintainer review on PR #393, UI only: - Rename distribution -> categorical: re-export CategoricalDecl, CategoricalSeries and CategoricalTimelineRequest from @quent/utils (the Distribution* bindings are gone) and update all usages in the client fetcher, data-flow utils/hooks and tests. MeasureDecl and DimensionKeyDecl are unchanged. - Drop the DataFlowTimelineResponse enum: the endpoint now returns DataFlowTimelineBinned directly and signals unsupported analyzers with HTTP 501. fetchDataFlow resolves the 501 to a null sentinel instead of throwing, so react-query settles as "unavailable" without retries; normalizeDataFlowResponse/isDataFlowAvailable lose the "Unsupported"/{Binned: ...} cases and tests now exercise the bare binned object plus the null path (new api.test.ts covers 501 -> null, other errors still throw). - Extract DataFlowTierLegend out of DAGLegend.tsx into its own DataFlowTierLegend.tsx with its frame-specific imports, behavior unchanged (CategoricalLegend is now exported for reuse). typecheck, test:run (583), lint and build are all green. Co-Authored-By: Claude Fable 5 --- ui/packages/@quent/client/src/api.test.ts | 67 ++++++++++ ui/packages/@quent/client/src/api.ts | 38 ++++-- ui/packages/@quent/client/src/dataFlow.ts | 3 + .../@quent/components/src/dag/DAGLegend.tsx | 57 +-------- .../components/src/dag/DataFlowTierLegend.tsx | 54 ++++++++ .../hooks/src/dataFlow/dataFlow.utils.test.ts | 21 ++- .../hooks/src/dataFlow/dataFlow.utils.ts | 26 ++-- .../hooks/src/dataFlow/useDataFlowSync.ts | Bin 5830 -> 5903 bytes ui/packages/@quent/utils/src/types/index.ts | 7 +- ui/src/components/DataFlowOverlay.test.tsx | 121 ++++++++---------- ui/src/components/QueryPlan.tsx | 9 +- 11 files changed, 239 insertions(+), 164 deletions(-) create mode 100644 ui/packages/@quent/client/src/api.test.ts create mode 100644 ui/packages/@quent/components/src/dag/DataFlowTierLegend.tsx 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 f6375e84b..88e4323a7 100644 --- a/ui/packages/@quent/client/src/api.ts +++ b/ui/packages/@quent/client/src/api.ts @@ -11,8 +11,8 @@ import type { SingleTimelineRequest, SingleTimelineResponse, BulkTimelineRequest, - DataFlowTimelineResponse, - DistributionTimelineRequest, + CategoricalTimelineRequest, + DataFlowTimelineBinned, QueryFilter, OperatorFilter, EntityRef, @@ -26,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)]))}` @@ -48,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}`); @@ -109,9 +119,10 @@ export async function fetchBulkTimelines( } /** - * Fetch the data-flow distribution timeline for a query (all operators in one - * response). Returns `"Unsupported"` when the engine's analyzer does not - * implement the data-flow protocol. + * 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( @@ -119,16 +130,21 @@ export async function fetchDataFlow( queryId: string, config: TimelineConfig, measures: string[] = [] -): Promise { - const request: DistributionTimelineRequest = { +): Promise { + const request: CategoricalTimelineRequest = { measures, config, app_params: { query_id: queryId }, }; - return apiFetch(`/engines/${engineId}/timeline/data-flow`, { + 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 index 4ef141754..3a6d1a7d2 100644 --- a/ui/packages/@quent/client/src/dataFlow.ts +++ b/ui/packages/@quent/client/src/dataFlow.ts @@ -21,6 +21,9 @@ export const dataFlowQueryOptions = ( ) => 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, diff --git a/ui/packages/@quent/components/src/dag/DAGLegend.tsx b/ui/packages/@quent/components/src/dag/DAGLegend.tsx index b70a3d57c..f1e2fcabf 100644 --- a/ui/packages/@quent/components/src/dag/DAGLegend.tsx +++ b/ui/packages/@quent/components/src/dag/DAGLegend.tsx @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { memo, useMemo } from 'react'; +import { useMemo } from 'react'; import { Panel } from '@xyflow/react'; import { useNodeColoringValue, @@ -12,9 +12,6 @@ import { useSelectedEdgeColorField, useDataFlowEnabled, useDataFlowMeta, - useDataFlowFrame, - formatDataFlowValueCompact, - type DataFlowMeta, } from '@quent/hooks'; import { cn, @@ -24,6 +21,7 @@ import { 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'; @@ -75,7 +73,8 @@ interface CategoricalLegendProps { entrySuffixes?: ReadonlyMap; } -const CategoricalLegend = ({ +/** Shared category-swatch legend group (also used by `DataFlowTierLegend`). */ +export const CategoricalLegend = ({ field, categoryMap, dimmedLabels, @@ -183,54 +182,6 @@ function EdgeLegendContent({ return ; } -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. - */ -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 ( - - ); -}); - interface DAGLegendProps { /** Whether dark mode is active. Passed explicitly to decouple from ThemeContext. */ isDark: boolean; 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/hooks/src/dataFlow/dataFlow.utils.test.ts b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts index 8517aba09..27df9d3b4 100644 --- a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.test.ts @@ -109,32 +109,29 @@ const BYTES_SPEC: QuantitySpec = { const BIN: DataFlowBinConfig = { startS: 0, endS: 8, binDurationS: 2, numBins: NUM_BINS }; describe('normalizeDataFlowResponse', () => { - it('returns null for "Unsupported"', () => { - expect(normalizeDataFlowResponse('Unsupported')).toBeNull(); - }); - - it('returns null for null/undefined', () => { + it('returns null for null/undefined (unsupported analyzer or not loaded)', () => { expect(normalizeDataFlowResponse(null)).toBeNull(); expect(normalizeDataFlowResponse(undefined)).toBeNull(); }); - it('unwraps the Binned variant', () => { + it('passes a binned response through unchanged', () => { const binned = makeBinned(OPERATORS); - expect(normalizeDataFlowResponse({ Binned: binned })).toBe(binned); + expect(normalizeDataFlowResponse(binned)).toBe(binned); }); }); describe('isDataFlowAvailable', () => { - it('is false for "Unsupported"', () => { - expect(isDataFlowAvailable('Unsupported')).toBe(false); + 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({ Binned: makeBinned({}) })).toBe(false); + expect(isDataFlowAvailable(makeBinned({}))).toBe(false); }); - it('is true for a non-empty Binned response', () => { - expect(isDataFlowAvailable({ Binned: makeBinned(OPERATORS) })).toBe(true); + it('is true for a non-empty binned response', () => { + expect(isDataFlowAvailable(makeBinned(OPERATORS))).toBe(true); }); }); diff --git a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts index 47205c5f9..159c66e17 100644 --- a/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts +++ b/ui/packages/@quent/hooks/src/dataFlow/dataFlow.utils.ts @@ -5,12 +5,11 @@ // 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 `DistributionDecl` — no hardcoded semantics. +// response's `CategoricalDecl` — no hardcoded semantics. import type { + CategoricalDecl, DataFlowTimelineBinned, - DataFlowTimelineResponse, - DistributionDecl, FsmTypeDecl, QuantitySpec, ZoomRange, @@ -29,7 +28,7 @@ export interface DataFlowBinConfig { * Presentation metadata for the data-flow overlay, derived once per response. */ export interface DataFlowMeta { - decl: DistributionDecl; + decl: CategoricalDecl; /** * FSM type declaration referenced by `decl.entity_type_name` (from the * query bundle), when present. Drives state colors so they match the @@ -121,21 +120,18 @@ export interface DataFlowFrame { } /** - * Normalize the externally-tagged response. Returns `null` for - * `"Unsupported"` or malformed values. + * 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: DataFlowTimelineResponse | null | undefined + response: DataFlowTimelineBinned | null | undefined ): DataFlowTimelineBinned | null { - if (!response || response === 'Unsupported') return null; - if (typeof response !== 'object' || !('Binned' in response)) return null; - return response.Binned; + return response ?? null; } /** Whether the feature should be shown at all: supported and non-empty. */ -export function isDataFlowAvailable( - response: DataFlowTimelineResponse | null | undefined -): boolean { +export function isDataFlowAvailable(response: DataFlowTimelineBinned | null | undefined): boolean { const binned = normalizeDataFlowResponse(response); return binned != null && Object.keys(binned.operators).length > 0; } @@ -425,7 +421,7 @@ export function buildDataFlowMeta( */ export function resolveDataFlowMeasure( selected: string | null, - decl: DistributionDecl + decl: CategoricalDecl ): string | null { const isDeclared = (name: string) => decl.measures.some(m => m.name === name); if (selected != null && isDeclared(selected)) return selected; @@ -441,7 +437,7 @@ export function resolveDataFlowMeasure( */ export function resolveDataFlowLabelMeasure( selected: string | null, - decl: DistributionDecl, + decl: CategoricalDecl, barMeasure: string ): string { if (selected != null && decl.measures.some(m => m.name === selected)) return selected; diff --git a/ui/packages/@quent/hooks/src/dataFlow/useDataFlowSync.ts b/ui/packages/@quent/hooks/src/dataFlow/useDataFlowSync.ts index b37fcd0efaec6bce9d0d9ef671a8986c0cf80bda..d6a74fda2022c6e2ef1ff8ac0ab4a53361d4505e 100644 GIT binary patch delta 110 zcmX@6+po7lo{`NdGcPYSWwSbC2dkI9mX-p9uTYYio0^lEmujt$kXM?Mlb~R$P?}d< zT2PQ*RFayakeHX4Q(2W-q@dvu5)z!32oWjvulxWQRt_2f diff --git a/ui/packages/@quent/utils/src/types/index.ts b/ui/packages/@quent/utils/src/types/index.ts index 14d825d78..882f52caa 100644 --- a/ui/packages/@quent/utils/src/types/index.ts +++ b/ui/packages/@quent/utils/src/types/index.ts @@ -8,12 +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 { DataFlowTimelineResponse } from '../../../../../../examples/simulator/server/ts-bindings/DataFlowTimelineResponse'; export type { DimensionKeyDecl } from '../../../../../../examples/simulator/server/ts-bindings/DimensionKeyDecl'; -export type { DistributionDecl } from '../../../../../../examples/simulator/server/ts-bindings/DistributionDecl'; -export type { DistributionSeries } from '../../../../../../examples/simulator/server/ts-bindings/DistributionSeries'; -export type { DistributionTimelineRequest } from '../../../../../../examples/simulator/server/ts-bindings/DistributionTimelineRequest'; 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 index 98fc1637f..279fed0bc 100644 --- a/ui/src/components/DataFlowOverlay.test.tsx +++ b/ui/src/components/DataFlowOverlay.test.tsx @@ -21,36 +21,34 @@ import { registerAxisPointerSync, unregisterAxisPointerSync, } from '@quent/components'; -import type { DataFlowTimelineResponse, EntityRef, QueryBundle } from '@quent/utils'; +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: DataFlowTimelineResponse = { - Binned: { - 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] }, - }, +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] }, }, }, }, @@ -59,16 +57,14 @@ const RESPONSE: DataFlowTimelineResponse = { // 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: DataFlowTimelineResponse = { - Binned: { - ...RESPONSE.Binned, - operators: { - ...RESPONSE.Binned.operators, - 'op-2': { - values: { - tasks: { - queueing: { memory: [0, 0, 0, 1000] }, - }, +const NARROW_RESPONSE: DataFlowTimelineBinned = { + ...RESPONSE, + operators: { + ...RESPONSE.operators, + 'op-2': { + values: { + tasks: { + queueing: { memory: [0, 0, 0, 1000] }, }, }, }, @@ -77,19 +73,17 @@ const NARROW_RESPONSE: DataFlowTimelineResponse = { // 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: DataFlowTimelineResponse = { - Binned: { - ...RESPONSE.Binned, - 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 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] }, }, }, }, @@ -128,7 +122,8 @@ const QUERY_BUNDLE = { } as unknown as QueryBundle; interface HarnessProps { - response: DataFlowTimelineResponse; + /** 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). */ @@ -168,9 +163,9 @@ function Harness({ ); } -function renderOverlay(props: DataFlowTimelineResponse | HarnessProps) { +function renderOverlay(props: DataFlowTimelineBinned | null | HarnessProps) { const harnessProps: HarnessProps = - typeof props === 'object' && 'response' in props ? props : { response: props }; + props !== null && 'response' in props ? props : { response: props }; return render( @@ -194,8 +189,8 @@ function stateSegmentWidths(): string[] { } describe('data-flow overlay components', () => { - it('renders nothing when the response is "Unsupported"', () => { - renderOverlay('Unsupported'); + 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(); }); @@ -324,11 +319,9 @@ describe('playback while the overlay is disabled', () => { describe('analyzer-declared default measure', () => { // Same data as RESPONSE, but the analyzer declares bytes as the default. - const BYTES_DEFAULT_RESPONSE: DataFlowTimelineResponse = { - Binned: { - ...RESPONSE.Binned, - decl: { ...RESPONSE.Binned.decl, default_measure: 'bytes' }, - }, + const BYTES_DEFAULT_RESPONSE: DataFlowTimelineBinned = { + ...RESPONSE, + decl: { ...RESPONSE.decl, default_measure: 'bytes' }, }; it('starts the flow bars on the declared default measure', () => { @@ -458,7 +451,7 @@ describe('DAGNodeInfoPanel matrix under tier selection', () => { describe('DAGLegend under tier selection', () => { function renderLegend( selectedDimensions: ReadonlySet | null, - response: DataFlowTimelineResponse = RESPONSE + response: DataFlowTimelineBinned = RESPONSE ) { return render( @@ -519,10 +512,8 @@ describe('DAGLegend under tier selection', () => { it('formats totals in the current flow measure via its quantity spec', () => { renderLegend(null, { - Binned: { - ...RESPONSE.Binned, - decl: { ...RESPONSE.Binned.decl, default_measure: 'bytes' }, - }, + ...RESPONSE, + decl: { ...RESPONSE.decl, default_measure: 'bytes' }, }); const slider = screen.getByRole('slider'); fireEvent.keyDown(slider, { key: 'ArrowRight' }); diff --git a/ui/src/components/QueryPlan.tsx b/ui/src/components/QueryPlan.tsx index a2d89d362..11d0be1a4 100644 --- a/ui/src/components/QueryPlan.tsx +++ b/ui/src/components/QueryPlan.tsx @@ -53,10 +53,11 @@ export function QueryPlan({ queryId, engineId }: { queryId: string; engineId: st const { dagData, treeData, error: dagError } = useQueryPlanVisualization(queryBundle, planId); - // Data-flow overlay: fetch the distribution 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 — `"Unsupported"` or an - // empty result hides the playhead, bars, controls, and legend entries. + // 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( From c7571a8790d8f17d1771d450fc0ccc4190a398a8 Mon Sep 17 00:00:00 2001 From: Felipe Aramburu Date: Fri, 17 Jul 2026 12:31:17 -0500 Subject: [PATCH 14/14] style(docs): collapse double blank line left by section removal Co-Authored-By: Claude Fable 5 --- docs/domains/query_engine/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/domains/query_engine/README.md b/docs/domains/query_engine/README.md index dde8c5a5b..6f4cc4684 100644 --- a/docs/domains/query_engine/README.md +++ b/docs/domains/query_engine/README.md @@ -189,7 +189,6 @@ act as [Resource Groups][resource-group], forming a hierarchy through which resource usages can be aggregated. See [Resource Group][resource-group] for details. - [mutual-exclusion]: ../../modeling/README.md#mutual-exclusion [engine]: #engine [entity]: ../../modeling/entity.md