diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index 7748710518..703d340f30 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -20128,6 +20128,7 @@ "resetFocus": "52e4a8f135999837", "resetZoom": "3a43acdce0ce0a6c", "rootAtBottom": "c56857a7058a5f54", + "runNeedsProfileType": "cce3ebf0fef22891", "runOptions": "351dabc4ae8669a2", "runQuery": "1837e00c45e8663d", "searchForFunction": "47fc3e0c4a4f8aa6", diff --git a/src/api/http/src/handler/http/router/mod.rs b/src/api/http/src/handler/http/router/mod.rs index 67abd19f6c..0603df5537 100644 --- a/src/api/http/src/handler/http/router/mod.rs +++ b/src/api/http/src/handler/http/router/mod.rs @@ -3798,7 +3798,6 @@ mod tests { "custom_logo_text", "editor_suggest_mode", "native_login_enabled", - "rum", "sso_enabled", "telemetry_enabled", ], @@ -3877,6 +3876,10 @@ mod tests { payload.get("version").is_some(), "authenticated config carries the version" ); + assert!( + payload.get("rum").is_some_and(Value::is_object), + "the RUM SDK settings are served only to signed-in callers" + ); assert!( payload.get("instance").is_none(), "the instance id is a fingerprinting handle no UI consumer reads; it must not be served" diff --git a/src/api/management/src/request/status/mod.rs b/src/api/management/src/request/status/mod.rs index 71443a7a39..c96ae67ab5 100644 --- a/src/api/management/src/request/status/mod.rs +++ b/src/api/management/src/request/status/mod.rs @@ -319,7 +319,6 @@ struct ConfigBootstrapResponse { custom_hide_self_logo: bool, /// The editors need it before the authenticated config lands, or `off` arrives too late. editor_suggest_mode: String, - rum: Rum, } #[derive(Serialize, serde::Deserialize)] @@ -462,7 +461,6 @@ pub async fn zo_config_bootstrap() -> impl IntoResponse { custom_logo_dark_img, custom_hide_self_logo, editor_suggest_mode: cfg.limit.editor_suggest_mode.clone(), - rum: Rum::from_cfg(cfg.clone()), }) } diff --git a/src/api/search/src/profiles/mod.rs b/src/api/search/src/profiles/mod.rs index f2e6e97803..7196d9ca33 100644 --- a/src/api/search/src/profiles/mod.rs +++ b/src/api/search/src/profiles/mod.rs @@ -66,7 +66,10 @@ fn default_eq_op() -> String { #[derive(Debug, Clone, Default, Deserialize, Serialize, ToSchema)] pub struct ProfilesQueryBody { + /// Missing reads as 0 so `parse_required_time_range` reports it, not the JSON parser. + #[serde(default)] pub start_time: i64, + #[serde(default)] pub end_time: i64, #[serde(default)] pub data_source: Option, @@ -651,4 +654,21 @@ mod tests { let response = SearchResponse::default(); assert!(reject_partial_search_response(response).is_ok()); } + + #[tokio::test] + async fn post_body_without_time_range_gets_the_required_time_range_message() { + let body: ProfilesQueryBody = + json::from_str(r#"{"profile_type":"cpu","profile_unit":"nanoseconds"}"#).unwrap(); + let body = resolve_query_body(&Method::POST, Ok(Json(body)), &HashMap::new()).unwrap(); + let resp = parse_required_time_range(body.start_time, body.end_time).unwrap_err(); + assert_eq!(resp.status(), axum::http::StatusCode::BAD_REQUEST); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let text = String::from_utf8(bytes.to_vec()).unwrap(); + assert!( + text.contains("start_time and end_time are required (microseconds)"), + "{text}" + ); + } } diff --git a/src/api/search/src/traces/list/plan.rs b/src/api/search/src/traces/list/plan.rs index af45cd34ac..19d6d5ba55 100644 --- a/src/api/search/src/traces/list/plan.rs +++ b/src/api/search/src/traces/list/plan.rs @@ -15,14 +15,16 @@ use arrow_schema::Schema; use config::{ - TIMESTAMP_COL_NAME, + TIMESTAMP_COL_NAME, get_config, meta::search::{Query, Request, SearchEventType}, utils::time::now_micros, }; use hashbrown::HashMap; use super::{ - query::{build_llm_trace_query, build_trace_query, trace_id_list_sql}, + query::{ + DURATION_CANDIDATE_SELECT, build_llm_trace_query, build_trace_query, trace_id_list_sql, + }, response::TraceResponseItem, }; use crate::{common::utils::http::get_use_cache_from_request, traces::schema_compat}; @@ -73,7 +75,7 @@ impl TraceListPlan { let mut request = base_request.clone(); request.query.sql = format!( "{} WHERE trace_id IN ({}) GROUP BY trace_id", - self.select_sql(stream_name), + self.select_sql(stream_name, false), trace_id_list_sql(trace_ids) ); request.query.from = 0; @@ -93,9 +95,9 @@ impl TraceListPlan { } fn sql(&self, stream_name: &str) -> String { - let sql = self.select_sql(stream_name); + let sql = self.select_sql(stream_name, self.sort.ranks_by_candidate(self.mode)); let filter = self.filter(); - let order = self.sort.sql(); + let order = self.sort.sql(self.mode); if filter.is_empty() { format!("{sql} GROUP BY trace_id ORDER BY {order}") } else { @@ -103,8 +105,12 @@ impl TraceListPlan { } } - fn select_sql(&self, stream_name: &str) -> String { - let extra_selects = self.columns.aggregate_selects(); + // Only the streamed descending duration Q1 reads the candidate; Q2a and other sorts skip it. + fn select_sql(&self, stream_name: &str, with_candidate: bool) -> String { + let mut extra_selects = self.columns.aggregate_selects(); + if with_candidate { + extra_selects = format!("{DURATION_CANDIDATE_SELECT}, {extra_selects}"); + } match &self.llm_schema { Some(llm_schema) => build_llm_trace_query(stream_name, llm_schema, &extra_selects), None => build_trace_query(stream_name, &extra_selects), @@ -154,14 +160,17 @@ impl TraceListOptions { if end == 0 { return Err("end_time is empty".to_string()); } + let size = query + .get("size") + .and_then(|v| v.parse::().ok()) + .filter(|size| *size > 0) + .unwrap_or_else(|| get_config().limit.query_default_limit); let mut options = Self { filter, from: query .get("from") .map_or(0, |v| v.parse::().unwrap_or(0)), - size: query - .get("size") - .map_or(10, |v| v.parse::().unwrap_or(10)), + size, range: (start, end), timeout: query .get("timeout") @@ -249,7 +258,11 @@ impl TraceSort { }) } - pub fn sql(self) -> String { + pub fn sql(self, mode: TraceListMode) -> String { + if self.ranks_by_candidate(mode) { + // Same tiebreaker as the merge, so every page's per-partition LIMIT cuts ties alike. + return "zo_sql_candidate DESC, trace_id ASC".to_string(); + } let field = if self.by_duration { "zo_sql_duration" } else { @@ -259,6 +272,11 @@ impl TraceSort { format!("{field} {order}") } + // A crossing trace reaches the candidate (longest span or local range) in one partition. + pub fn ranks_by_candidate(self, mode: TraceListMode) -> bool { + mode == TraceListMode::Streaming && self.by_duration && !self.ascending + } + pub fn sort(self, hits: &mut [TraceResponseItem]) { let key = |hit: &TraceResponseItem| { if self.by_duration { @@ -343,17 +361,43 @@ mod tests { } #[test] - fn parsing_preserves_defaults_and_unclamped_pagination() { + fn parsing_preserves_defaults_and_unclamped_offset() { + let limit = get_config().limit.query_default_limit; for mode in [TraceListMode::Complete, TraceListMode::Streaming] { let mut query = query(); let defaults = TraceListOptions::parse(&query, mode).unwrap(); - assert_eq!((defaults.from, defaults.size, defaults.timeout), (0, 10, 0)); + assert_eq!( + (defaults.from, defaults.size, defaults.timeout), + (0, limit, 0) + ); query.insert("from".to_string(), "-2".to_string()); query.insert("size".to_string(), "-1".to_string()); let options = TraceListOptions::parse(&query, mode).unwrap(); - assert_eq!((options.from, options.size), (-2, -1)); - query.insert("size".to_string(), "invalid".to_string()); - assert_eq!(TraceListOptions::parse(&query, mode).unwrap().size, 10); + assert_eq!((options.from, options.size), (-2, limit)); + } + } + + #[test] + fn sizes_below_one_resolve_to_the_query_default_limit() { + let limit = get_config().limit.query_default_limit; + for (size, expected) in [ + (None, limit), + (Some("0"), limit), + (Some("-1"), limit), + (Some("-5"), limit), + (Some("invalid"), limit), + (Some("1"), 1), + (Some("2"), 2), + (Some("2000"), 2000), + ] { + let mut query = query(); + if let Some(size) = size { + query.insert("size".to_string(), size.to_string()); + } + for mode in [TraceListMode::Complete, TraceListMode::Streaming] { + let options = TraceListOptions::parse(&query, mode).unwrap(); + assert_eq!(options.size, expected, "{size:?} {mode:?}"); + } } } @@ -455,6 +499,65 @@ mod tests { } } + #[test] + fn only_the_streamed_descending_duration_sort_ranks_by_the_candidate() { + for (sort_order, expected) in [ + ( + "desc", + [ + "zo_sql_duration DESC", + "zo_sql_candidate DESC, trace_id ASC", + ], + ), + ("asc", ["zo_sql_duration ASC", "zo_sql_duration ASC"]), + ] { + let mut query = query(); + query.insert("sort_by".to_string(), "duration".to_string()); + query.insert("sort_order".to_string(), sort_order.to_string()); + let llm_schemas = [ + None, + Some(schema_compat::ValidatedLlmSchema::fallback(true)), + Some(schema_compat::ValidatedLlmSchema::fallback(false)), + ]; + let modes = [TraceListMode::Complete, TraceListMode::Streaming]; + for ((mode, expected), llm_schema) in modes + .into_iter() + .zip(expected) + .flat_map(|mode| llm_schemas.clone().map(|llm| (mode, llm))) + { + let plan = TraceListPlan { + options: TraceListOptions::parse(&query, mode).unwrap(), + sort: TraceSort::parse(&query).unwrap(), + columns: TraceColumns::default(), + llm_schema, + mode, + }; + let sql = plan.sql("traces"); + assert!( + sql.ends_with(&format!("GROUP BY trace_id ORDER BY {expected}")), + "{sql}" + ); + assert_eq!( + sql.contains(DURATION_CANDIDATE_SELECT), + expected.starts_with("zo_sql_candidate"), + "{sql}" + ); + let summary = + plan.summary_request("traces", &Request::default(), &["a".to_string()], (1, 2)); + assert!( + !summary.query.sql.contains("zo_sql_candidate"), + "{}", + summary.query.sql + ); + } + } + let mut query = query(); + query.insert("sort_by".to_string(), "start_time".to_string()); + let sort = TraceSort::parse(&query).unwrap(); + assert!(!sort.ranks_by_candidate(TraceListMode::Streaming)); + assert_eq!(sort.sql(TraceListMode::Streaming), "zo_sql_timestamp DESC"); + } + #[test] fn only_filtered_pages_and_the_streamed_duration_sort_recompute_every_trace() { for (sort_by, filter, expected) in [ diff --git a/src/api/search/src/traces/list/query.rs b/src/api/search/src/traces/list/query.rs index dbd6de9229..fa1c60ee5b 100644 --- a/src/api/search/src/traces/list/query.rs +++ b/src/api/search/src/traces/list/query.rs @@ -39,6 +39,9 @@ use crate::traces::{ // The lookup only widens summaries, so a slow index must not hold back the page. const RANGE_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2); +// `duration` is in microseconds while `start_time` and `end_time` are in nanoseconds. +pub const DURATION_CANDIDATE_SELECT: &str = + "GREATEST(max(duration), (max(end_time) - min(start_time)) / 1000) AS zo_sql_candidate"; pub struct TraceSearchContext { pub org_id: String, diff --git a/src/api/search/src/traces/list/stream.rs b/src/api/search/src/traces/list/stream.rs index fcc894bcb0..064daf026b 100644 --- a/src/api/search/src/traces/list/stream.rs +++ b/src/api/search/src/traces/list/stream.rs @@ -13,7 +13,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use axum::response::Response; use config::{ @@ -36,6 +36,80 @@ use super::{ }; use crate::search::error_utils::map_error_to_http_response; +#[cfg_attr(test, derive(Clone))] +struct CandidateMerge { + cap: usize, + // Largest candidate per trace id, kept for pruned ids too, so a smaller row cannot re-enter. + candidates: HashMap, + rows: HashMap, +} + +impl CandidateMerge { + fn new(cap: usize) -> Self { + Self { + cap, + candidates: HashMap::new(), + rows: HashMap::new(), + } + } + + fn push_hits(&mut self, hits: Vec) { + for hit in hits { + let Some(trace_id) = trace_id_of(&hit) + .filter(|id| !id.is_empty()) + .map(str::to_string) + else { + continue; + }; + let candidate = candidate_of(&hit); + // A map, not the TopKHeap: a later partition can report a larger candidate for an id. + if self + .candidates + .get(&trace_id) + .is_some_and(|best| *best >= candidate) + { + continue; + } + self.candidates.insert(trace_id.clone(), candidate); + self.rows.insert(trace_id, hit); + } + self.prune(); + } + + // Dropping past the cap is safe: candidates never shrink, so the cap-th key never falls. + fn prune(&mut self) { + if self.rows.len() <= self.cap { + return; + } + let mut keys: Vec<(i64, String)> = self + .rows + .iter() + .map(|(id, row)| (candidate_of(row), id.clone())) + .collect(); + keys.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + for (_, id) in keys.drain(self.cap..) { + self.rows.remove(&id); + } + } + + fn len(&self) -> usize { + self.candidates.len() + } + + fn into_page(self, from: i64, size: i64) -> Vec { + let mut rows: Vec = self.rows.into_values().collect(); + rows.sort_by(|a, b| { + candidate_of(b) + .cmp(&candidate_of(a)) + .then_with(|| trace_id_of(a).cmp(&trace_id_of(b))) + }); + rows.into_iter() + .skip(from.max(0) as usize) + .take(size.max(0) as usize) + .collect() + } +} + pub fn stream_response(ctx: TraceSearchContext, plan: TraceListPlan, span: Span) -> Response { let trace_id = ctx.trace_id.clone(); let (tx, rx) = mpsc::channel::>(100); @@ -139,6 +213,7 @@ async fn run( let total_partitions = partitions.len(); let is_non_ts_order_by = plan.sort.by_duration; + let ranks_by_candidate = plan.sort.ranks_by_candidate(plan.mode); let partitions_desc: Vec<[i64; 2]> = if !is_non_ts_order_by && plan.sort.ascending { log::info!( "[TRACES_STREAM trace_id {trace_id}] using {} time partitions, order=ASC", @@ -150,7 +225,15 @@ async fn run( "[TRACES_STREAM trace_id {trace_id}] using {} time partitions, order={}", partitions.len(), if is_non_ts_order_by { - format!("non-ts ({}), TopKHeap", plan.sort.sql()) + format!( + "non-ts ({}), {}", + plan.sort.sql(plan.mode), + if ranks_by_candidate { + "candidate merge" + } else { + "TopKHeap" + } + ) } else { "DESC".to_string() } @@ -165,7 +248,8 @@ async fn run( }; let non_ts_col = "zo_sql_duration".to_string(); let non_ts_is_desc = !plan.sort.ascending; - let mut topk_heap: Option = if is_non_ts_order_by { + let mut candidate_merge = ranks_by_candidate.then(|| CandidateMerge::new(heap_k)); + let mut topk_heap: Option = if is_non_ts_order_by && !ranks_by_candidate { Some(TopKHeap::new(heap_k, &[(non_ts_col, non_ts_is_desc)])) } else { None @@ -253,15 +337,20 @@ async fn run( } if is_non_ts_order_by { - for item in &deduped_hits { - if let Some(tid) = item.get("trace_id").and_then(|v| v.as_str()) - && !tid.is_empty() - { - seen_trace_ids.insert(tid.to_string()); + if let Some(merge) = candidate_merge.as_mut() { + // Every partition reports the same trace again; the merge keeps the largest. + merge.push_hits(deduped_hits); + } else { + for item in &deduped_hits { + if let Some(tid) = item.get("trace_id").and_then(|v| v.as_str()) + && !tid.is_empty() + { + seen_trace_ids.insert(tid.to_string()); + } + } + if let Some(ref mut heap) = topk_heap { + heap.push_hits(deduped_hits); } - } - if let Some(ref mut heap) = topk_heap { - heap.push_hits(deduped_hits); } let progress = ((idx + 1) * 100) / total_partitions; let _ = sender @@ -344,10 +433,16 @@ async fn run( } if is_non_ts_order_by { - let deliverable_q1 = topk_heap - .take() - .map(|h| h.into_sorted_vec(from as usize)) - .unwrap_or_default(); + let (total, deliverable_q1) = match candidate_merge.take() { + Some(merge) => (merge.len(), merge.into_page(from, size)), + None => ( + total_across_partitions, + topk_heap + .take() + .map(|h| h.into_sorted_vec(from as usize)) + .unwrap_or_default(), + ), + }; if deliverable_q1.is_empty() { let _ = sender.send(Ok(StreamResponses::Done)).await; @@ -377,7 +472,7 @@ async fn run( for h in deliverable { results.add_hit(h); } - results.set_total(total_across_partitions); + results.set_total(total); if !range_error.is_empty() { results.function_error.push(range_error.clone()); } @@ -461,10 +556,122 @@ fn select_timestamp_page( hits.into_iter().skip(skip as usize).take(need).collect() } +fn trace_id_of(hit: &json::Value) -> Option<&str> { + hit.get("trace_id").and_then(json::Value::as_str) +} + +fn candidate_of(hit: &json::Value) -> i64 { + json::get_int_value(hit.get("zo_sql_candidate").unwrap_or_default()) +} + #[cfg(test)] mod tests { use super::*; + fn row(trace_id: &str, candidate: i64) -> json::Value { + json::json!({"trace_id": trace_id, "zo_sql_candidate": candidate}) + } + + fn ids(rows: &[json::Value]) -> Vec<&str> { + rows.iter().filter_map(trace_id_of).collect() + } + + // The Q1 order `zo_sql_candidate DESC, trace_id ASC` (or ties cut the other way) then LIMIT. + fn sql_cut(mut hits: Vec, limit: usize, ids_ascending: bool) -> Vec { + hits.sort_by(|a, b| { + let by_id = trace_id_of(a).cmp(&trace_id_of(b)); + candidate_of(b).cmp(&candidate_of(a)).then_with(|| { + if ids_ascending { + by_id + } else { + by_id.reverse() + } + }) + }); + hits.truncate(limit); + hits + } + + fn page( + partitions: &[Vec], + from: i64, + size: i64, + ids_ascending: bool, + ) -> Vec { + let heap_k = (from + size) as usize; + let mut merge = CandidateMerge::new(heap_k); + for hits in partitions { + merge.push_hits(sql_cut(hits.clone(), heap_k, ids_ascending)); + } + ids(&merge.into_page(from, size)) + .into_iter() + .map(str::to_string) + .collect() + } + + #[test] + fn candidate_merge_keeps_the_largest_row_per_trace_and_pages_by_candidate() { + // Example 3 of the spec: four partitions newest first, page size 2, trace A crosses all. + let partitions = [ + vec![row("B", 4), row("E", 3)], + vec![row("A", 5), row("D", 4)], + vec![row("A", 5), row("C", 2)], + vec![row("A", 12)], + ]; + let mut merge = CandidateMerge::new(10); + for hits in partitions { + merge.push_hits(hits); + } + merge.push_hits(vec![row("A", 1), row("", 99)]); + assert_eq!(merge.len(), 5); + assert_eq!(merge.rows["A"], row("A", 12)); + assert_eq!(ids(&merge.clone().into_page(0, 2)), ["A", "B"]); + assert_eq!(ids(&merge.clone().into_page(2, 2)), ["D", "E"]); + assert_eq!(ids(&merge.into_page(-1, 10)), ["A", "B", "D", "E", "C"]); + } + + #[test] + fn candidate_merge_ranks_the_issue_example_by_its_root_span() { + // Issue 15507: A = 9 s root span in the older partition plus a 1 s span in the newer one. + let mut merge = CandidateMerge::new(1); + merge.push_hits(vec![row("B", 2_000_000), row("A", 1_000_000)]); + merge.push_hits(vec![row("A", 9_000_000)]); + assert_eq!(merge.len(), 2); + assert_eq!(ids(&merge.into_page(0, 1)), ["A"]); + } + + #[test] + fn candidate_merge_keeps_at_most_cap_rows_and_readmits_a_pruned_id_that_grows() { + // from=1&size=1 keeps two rows: C is pruned, ignored while smaller, back once larger. + let mut merge = CandidateMerge::new(2); + merge.push_hits(vec![row("A", 5), row("B", 4), row("C", 3)]); + assert_eq!(merge.rows.len(), 2); + assert!(!merge.rows.contains_key("C")); + merge.push_hits(vec![row("C", 2)]); + assert!(!merge.rows.contains_key("C")); + merge.push_hits(vec![row("C", 10), row("B", 4)]); + assert_eq!(merge.rows.len(), 2); + assert_eq!(merge.len(), 3); + assert_eq!(ids(&merge.clone().into_page(0, 2)), ["C", "A"]); + assert_eq!(ids(&merge.into_page(1, 1)), ["A"]); + } + + #[test] + fn the_trace_id_tiebreaker_keeps_tied_candidates_stable_across_pages() { + // Six traces tie at the cutoff; every page fetches `LIMIT from + size` per partition. + let partitions = vec![ + vec![row("D", 5), row("C", 5), row("B", 5), row("A", 5)], + vec![row("F", 5), row("E", 5), row("G", 3)], + ]; + assert_eq!(page(&partitions, 0, 2, true), ["A", "B"]); + assert_eq!(page(&partitions, 2, 2, true), ["C", "D"]); + assert_eq!(page(&partitions, 4, 2, true), ["E", "F"]); + assert_eq!(page(&partitions, 6, 2, true), ["G"]); + // Without the tiebreaker a partition can cut the ties the other way: page 2 repeats page 1. + assert_eq!(page(&partitions, 0, 2, false), ["C", "D"]); + assert_eq!(page(&partitions, 2, 2, false), ["C", "D"]); + } + #[test] fn timestamp_pagination_remembers_skipped_and_undelivered_ids() { let hits = vec![ diff --git a/src/api/search/src/traces/mod.rs b/src/api/search/src/traces/mod.rs index a76a539e22..a41bbf222d 100644 --- a/src/api/search/src/traces/mod.rs +++ b/src/api/search/src/traces/mod.rs @@ -186,7 +186,7 @@ pub async fn traces_write( ("stream_name" = String, Path, description = "Stream name"), ("filter" = Option, Query, description = "filter, eg: a=b AND c=d"), ("from" = i64, Query, description = "from"), - ("size" = i64, Query, description = "size"), + ("size" = i64, Query, description = "page size; 0, negative, invalid or omitted uses ZO_QUERY_DEFAULT_LIMIT"), ("start_time" = i64, Query, description = "start time"), ("end_time" = i64, Query, description = "end time"), ("timeout" = Option, Query, description = "timeout, seconds"), @@ -305,7 +305,7 @@ pub async fn get_latest_traces( tag = "Traces", operation_id = "GetLatestTracesStream", summary = "Stream recent trace data", - description = "Streams the most recent trace data from a specific stream within a time range using HTTP/2 server-sent events. Results are returned progressively as each time partition is processed, reducing time-to-first-result for large time ranges.", + description = "Streams the most recent trace data from a specific stream within a time range using HTTP/2 server-sent events. Results are returned progressively as each time partition is processed, reducing time-to-first-result for large time ranges. With sort_by=duration, ranking across partitions is approximate; each page is sorted by full trace duration.", security( ("Authorization"= []) ), @@ -314,11 +314,11 @@ pub async fn get_latest_traces( ("stream_name" = String, Path, description = "Stream name"), ("filter" = Option, Query, description = "filter, eg: a=b AND c=d"), ("from" = i64, Query, description = "from"), - ("size" = i64, Query, description = "size"), + ("size" = i64, Query, description = "page size; 0, negative, invalid or omitted uses ZO_QUERY_DEFAULT_LIMIT"), ("start_time" = i64, Query, description = "start time"), ("end_time" = i64, Query, description = "end time"), ("timeout" = Option, Query, description = "timeout, seconds"), - ("sort_by" = Option, Query, description = "sort by field: start_time, duration (default: start_time)"), + ("sort_by" = Option, Query, description = "sort by field: start_time, duration (default: start_time); see the endpoint description for the limits of the streamed duration sort"), ("sort_order" = Option, Query, description = "sort order: asc, desc (default: desc)"), ), responses( diff --git a/src/infra/src/db/postgres.rs b/src/infra/src/db/postgres.rs index 43dde67b13..13634b6104 100644 --- a/src/infra/src/db/postgres.rs +++ b/src/infra/src/db/postgres.rs @@ -169,6 +169,9 @@ impl super::Db for PostgresDb { Err(e) => { if let sqlx::Error::RowNotFound = e { return Err(Error::from(DbError::KeyNotExists(key.to_string()))); + } else if let sqlx::Error::Database(_) = e { + // stay typed: lc_messages localizes the text but not the SQLSTATE + return Err(Error::from(e)); } else { return Err(Error::from(DbError::DBOperError( e.to_string(), diff --git a/src/infra/src/lib.rs b/src/infra/src/lib.rs index 5fea6ddc72..8b8ea45236 100644 --- a/src/infra/src/lib.rs +++ b/src/infra/src/lib.rs @@ -65,8 +65,7 @@ pub async fn get_db_schema_version() -> Result { fn is_db_schema_version_missing(e: &errors::Error) -> bool { match e { errors::Error::DbError(errors::DbError::KeyNotExists(_)) => true, - // meta table not created yet: fresh install. the sqlite/postgres - // Db::get() wrap the sqlx error text into DBOperError + // meta table not created yet: sqlite Db::get() keeps only the error text errors::Error::DbError(errors::DbError::DBOperError(msg, _)) => is_fresh_db_message(msg), errors::Error::SqlxError(sqlx::Error::Database(e)) => { e.code().as_deref() == Some("42P01") // postgres: undefined_table @@ -172,6 +171,51 @@ pub async fn init() -> Result<(), anyhow::Error> { mod schema_version_tests { use super::*; + /// A postgres error whose message is localized while its SQLSTATE is not. + #[derive(Debug)] + struct PgError { + code: &'static str, + message: &'static str, + } + + impl std::fmt::Display for PgError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message) + } + } + + impl std::error::Error for PgError {} + + impl sqlx::error::DatabaseError for PgError { + fn message(&self) -> &str { + self.message + } + + fn code(&self) -> Option> { + Some(self.code.into()) + } + + fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) { + self + } + + fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) { + self + } + + fn into_error(self: Box) -> Box { + self + } + + fn kind(&self) -> sqlx::error::ErrorKind { + sqlx::error::ErrorKind::Other + } + } + + fn pg_error(code: &'static str, message: &'static str) -> errors::Error { + errors::Error::SqlxError(sqlx::Error::Database(Box::new(PgError { code, message }))) + } + #[test] fn test_missing_version_key_is_fresh_install() { let e = errors::Error::DbError(errors::DbError::KeyNotExists("/meta/kv/version".into())); @@ -180,8 +224,7 @@ mod schema_version_tests { #[test] fn test_missing_meta_table_is_fresh_install() { - // sqlite and postgres Db::get() wrap the table-missing error into - // DBOperError with the sqlx message text + // table-missing errors that reach the classifier as text only let e = errors::Error::DbError(errors::DbError::DBOperError( "error returned from database: (code: 1) no such table: meta".to_string(), "/meta/kv/version".to_string(), @@ -192,6 +235,9 @@ mod schema_version_tests { "/meta/kv/version".to_string(), )); assert!(is_db_schema_version_missing(&e)); + // postgres server with non-English lc_messages + let e = pg_error("42P01", "Relation »meta« existiert nicht"); + assert!(is_db_schema_version_missing(&e)); } #[test] @@ -220,5 +266,7 @@ mod schema_version_tests { "/meta/kv/version".to_string(), )); assert!(!is_db_schema_version_missing(&e)); + let e = pg_error("42501", "keine Berechtigung für Tabelle meta"); + assert!(!is_db_schema_version_missing(&e)); } } diff --git a/tests/api-testing/tests/search/test_traces_latest_stream_pagination.py b/tests/api-testing/tests/search/test_traces_latest_stream_pagination.py new file mode 100644 index 0000000000..fe5a2f7a2e --- /dev/null +++ b/tests/api-testing/tests/search/test_traces_latest_stream_pagination.py @@ -0,0 +1,211 @@ +"""Streamed trace list pagination: a size of 0, below 0 or omitted uses ZO_QUERY_DEFAULT_LIMIT [regression #15507]. + +The streamed endpoint used to stop before its first partition for size=-1 and size=0 and return no traces. Every +case runs through `traces/latest_stream` in all four sort combinations, because timestamp and duration sorts take +different paths through the partition loop. Offsets are checked on the descending sorts only. +""" + +from __future__ import annotations + +import logging +import secrets +import time +from collections.abc import Generator +from dataclasses import dataclass +from typing import Any + +import pytest + +from support.client import OpenObserveClient +from support.otlp_json import span, traces_request +from support.sse import read_sse_frames +from support.wait import wait_until + +logger = logging.getLogger(__name__) + +SEC_NS = 1_000_000_000 +MS_NS = 1_000_000 +SORTS = [("start_time", "desc"), ("start_time", "asc"), ("duration", "desc"), ("duration", "asc")] +DESC_SORTS = [("start_time", "desc"), ("duration", "desc")] +LETTERS = "ABCDEFGHIJKLMNO" +FEW_SERVICE = "e2e-15507-few" +MAIN_SERVICE = "e2e-15507-main" + + +@dataclass +class Seeded: + stream: str + window: tuple[int, int] + # Newest and longest first, so every descending sort lists them in this order. + ordered: list[str] + few: list[str] + + +def _trace(trace_id: str, start_ns: int, dur_ns: int, name: str) -> list[dict[str, Any]]: + # A covering root and a child that starts with it keep the trace in one partition, so its rank is exact. + root_id = secrets.token_hex(8) + root = span(name, trace_id=trace_id, span_id=root_id, time_unix_nano=start_ns, duration_nanos=dur_ns) + child = span( + f"{name}-child", + trace_id=trace_id, + span_id=secrets.token_hex(8), + time_unix_nano=start_ns, + duration_nanos=dur_ns // 2, + ) + child["parentSpanId"] = root_id + return [root, child] + + +def _ingest(client: OpenObserveClient, stream: str, spans_by_service: dict[str, list[dict[str, Any]]]) -> None: + for service, spans in spans_by_service.items(): + for i in range(0, len(spans), 500): + resp = client.post( + "v1/traces", json=traces_request(spans[i : i + 500], service=service), headers={"stream-name": stream} + ) + assert resp.status_code == 200, f"Precondition: traces must ingest, got {resp.status_code}: {resp.text}" + + +def _count_spans(client: OpenObserveClient, stream: str, window: tuple[int, int]) -> int: + body = { + "query": { + "sql": f'SELECT count(*) AS count FROM "{stream}"', + "start_time": window[0], + "end_time": window[1], + "from": 0, + "size": 10, + } + } + resp = client.post("_search?type=traces", json=body) + if resp.status_code != 200: + return 0 + hits = resp.json().get("hits") or [] + return int(hits[0].get("count", 0)) if hits else 0 + + +def _wait_for_spans(client: OpenObserveClient, stream: str, window: tuple[int, int], spans: int) -> None: + wait_until( + lambda: _count_spans(client, stream, window) >= spans, + timeout=90, + interval=2, + msg=f"all {spans} seeded spans must become searchable", + ) + + +def _latest_stream( + client: OpenObserveClient, stream: str, window: tuple[int, int], sort: tuple[str, str], **params: Any +) -> list[dict[str, Any]]: + query = { + "start_time": window[0], + "end_time": window[1], + "sort_by": sort[0], + "sort_order": sort[1], + "use_cache": "false", + **{k: v for k, v in params.items() if v is not None}, + } + resp = client.get(f"{stream}/traces/latest_stream", params=query, timeout=180) + assert resp.status_code == 200, f"latest_stream {query} failed: {resp.status_code} {resp.text[:300]}" + frames = read_sse_frames(resp) + errors = [data for event, data in frames if event == "error"] + assert not errors, f"latest_stream {query} streamed an error: {errors}" + return [hit for event, data in frames if event == "search_response_hits" for hit in data.get("hits") or []] + + +def _ids(hits: list[dict[str, Any]]) -> list[str]: + return [hit.get("trace_id") for hit in hits] + + +def _in_order(ids: list[str], sort: tuple[str, str]) -> list[str]: + return ids if sort[1] == "desc" else list(reversed(ids)) + + +def _delete_stream(client: OpenObserveClient, stream: str) -> None: + try: + client.streams.delete(stream, type_="traces") + except Exception as e: + logger.warning("could not delete trace stream %s: %s", stream, e) + + +@pytest.fixture(scope="module") +def seeded(client: OpenObserveClient) -> Generator[Seeded, None, None]: + """Fifteen traces A..O, 150 ms apart and 10 ms shorter each, A newest and longest; A, C and E form a smaller service.""" + stream = f"e2e_15507_{secrets.token_hex(4)}" + base_ns = (time.time_ns() // SEC_NS - 600) * SEC_NS + ordered = [secrets.token_hex(16) for _ in LETTERS] + few = [ordered[0], ordered[2], ordered[4]] + spans: dict[str, list[dict[str, Any]]] = {MAIN_SERVICE: [], FEW_SERVICE: []} + for i, trace_id in enumerate(ordered): + start_ns = base_ns + (len(ordered) - 1 - i) * 150 * MS_NS + service = FEW_SERVICE if trace_id in few else MAIN_SERVICE + spans[service] += _trace(trace_id, start_ns, (len(ordered) + 1 - i) * 10 * MS_NS, f"op-{LETTERS[i]}") + window = (base_ns // 1000 - 1_000_000, base_ns // 1000 + 4_000_000) + _ingest(client, stream, spans) + _wait_for_spans(client, stream, window, 2 * len(ordered)) + yield Seeded(stream=stream, window=window, ordered=ordered, few=few) + _delete_stream(client, stream) + + +@pytest.mark.parametrize("sort", SORTS, ids=lambda s: f"{s[0]}-{s[1]}") +@pytest.mark.parametrize( + ("size", "count"), + [(-1, 15), (0, 15), (-5, 15), (None, 15), (1, 1), (2, 2), (20, 15)], +) +def test_streamed_pages_follow_the_size_contract( + client: OpenObserveClient, seeded: Seeded, sort: tuple[str, str], size: int | None, count: int +): + """#15507: a size of 0, below 0 or omitted returns every seeded trace, a positive size its own count.""" + hits = _latest_stream(client, seeded.stream, seeded.window, sort, size=size, **{"from": 0}) + expected = _in_order(seeded.ordered, sort)[:count] + got = _ids(hits) + logger.info("sort=%s size=%s -> %s traces", sort, size, len(got)) + assert got == expected, f"sort={sort} size={size}: expected {len(expected)} traces in order, got {len(got)}" + + +@pytest.mark.parametrize("sort", DESC_SORTS, ids=lambda s: f"{s[0]}-{s[1]}") +@pytest.mark.parametrize(("size", "count"), [(-1, 13), (0, 13), (None, 13), (2, 2), (20, 13)]) +def test_descending_offsets_skip_the_first_traces( + client: OpenObserveClient, seeded: Seeded, sort: tuple[str, str], size: int | None, count: int +): + """#15507: from=2 skips the two newest or longest traces and returns the next page.""" + hits = _latest_stream(client, seeded.stream, seeded.window, sort, size=size, **{"from": 2}) + expected = seeded.ordered[2 : 2 + count] + assert _ids(hits) == expected, f"sort={sort} size={size} from=2: expected {len(expected)} traces from the third" + + +@pytest.mark.parametrize("sort", DESC_SORTS, ids=lambda s: f"{s[0]}-{s[1]}") +@pytest.mark.parametrize("size", [-1, 0, None, 2, 20]) +@pytest.mark.parametrize("from_", [15, 40]) +def test_an_offset_past_the_last_trace_returns_an_empty_page( + client: OpenObserveClient, seeded: Seeded, sort: tuple[str, str], size: int | None, from_: int +): + """#15507: an offset beyond the matching traces ends the stream normally with no rows.""" + hits = _latest_stream(client, seeded.stream, seeded.window, sort, size=size, **{"from": from_}) + assert hits == [], f"sort={sort} size={size} from={from_}: expected no traces, got {len(hits)}" + + +@pytest.mark.parametrize("sort", SORTS, ids=lambda s: f"{s[0]}-{s[1]}") +def test_filters_apply_to_every_size(client: OpenObserveClient, seeded: Seeded, sort: tuple[str, str]): + """#15507: a filter matching three traces returns those three for -1, 0, omitted and large sizes.""" + few = _in_order(seeded.few, sort) + flt = f"service_name = '{FEW_SERVICE}'" + for size in [-1, 0, None, 20]: + hits = _latest_stream(client, seeded.stream, seeded.window, sort, size=size, filter=flt) + assert _ids(hits) == few, f"sort={sort} size={size}: the filter must return its three traces, got {_ids(hits)}" + # The filtered page is re-aggregated over each whole trace: both spans and the root's duration. + for hit in hits: + assert hit.get("spans", [0])[0] == 2, f"filtered row lost its full span count: {hit}" + first = _latest_stream(client, seeded.stream, seeded.window, sort, size=1, filter=flt) + assert _ids(first) == few[:1], f"sort={sort}: size=1 must return the first filtered trace" + if sort in DESC_SORTS: + paged = _latest_stream(client, seeded.stream, seeded.window, sort, size=1, filter=flt, **{"from": 1}) + assert _ids(paged) == few[1:2], f"sort={sort}: size=1 from=1 must return the second filtered trace" + + +@pytest.mark.parametrize("sort", SORTS, ids=lambda s: f"{s[0]}-{s[1]}") +@pytest.mark.parametrize("size", [-1, 0, None, 2]) +def test_a_filter_that_matches_nothing_returns_an_empty_page( + client: OpenObserveClient, seeded: Seeded, sort: tuple[str, str], size: int | None +): + """#15507: no matching trace ends the stream normally with no rows for every size.""" + flt = "service_name = 'e2e-15507-nobody'" + hits = _latest_stream(client, seeded.stream, seeded.window, sort, size=size, filter=flt) + assert hits == [], f"sort={sort} size={size}: a filter that matches nothing must return no traces" diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 99af3c11d3..cce652b936 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -6798,9 +6798,19 @@ mod tests { async fn e2e_config() { let auth = setup(); let app = init_test_router(); - let headers = auth_headers(auth); - let (status, _body) = make_request(&app, Method::GET, "/config", Some(headers), None).await; + let (status, body) = make_request(&app, Method::GET, "/config", None, None).await; assert!(status.is_success()); + let bootstrap: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(bootstrap.get("rum").is_none(), "{bootstrap}"); + assert!(bootstrap.get("commit_hash").is_some(), "{bootstrap}"); + + let headers = auth_headers(auth); + let (status, body) = + make_request(&app, Method::GET, "/api/e2e/config", Some(headers), None).await; + assert!(status.is_success()); + let full: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(full["rum"]["version"], config::VERSION, "{full}"); + assert!(full["rum"].get("client_token").is_some(), "{full}"); } // Helper function to create pipeline via API diff --git a/tests/ui-testing/ci-matrix/ci_matrix.json b/tests/ui-testing/ci-matrix/ci_matrix.json index b19319a5f8..95b5b56c40 100644 --- a/tests/ui-testing/ci-matrix/ci_matrix.json +++ b/tests/ui-testing/ci-matrix/ci_matrix.json @@ -58,6 +58,7 @@ "dashboard-series-color-multiwindow.spec.js", "dashboard-config-advanced.spec.js", "dashboard-config-axis.spec.js", + "dashboard-stacked-axis-bounds.spec.js", "dashboard-config-gauge-maps.spec.js", "dashboard-config-general.spec.js", "dashboard-config-heatmap.spec.js", @@ -358,7 +359,9 @@ "logsRefreshCacheDropdown.spec.js", "logs-highlighting.spec.js", "logs-search-around-multistream.spec.js", - "logs-default-columns.spec.js" + "logs-default-columns.spec.js", + "logsResultsToolbar.spec.js", + "logsSavedFunctionOverwrite.spec.js" ], "disabled": [ { @@ -386,7 +389,9 @@ "logsActionReasons.spec.js", "logsSearchCancelled.spec.js", "logsToolbarPins.spec.js", - "logs-query-editor-keyboard.spec.js" + "logs-query-editor-keyboard.spec.js", + "logsCrossLinks.spec.js", + "logsFieldValuePaging.spec.js" ], "disabled": [ { diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-panel-actions.js b/tests/ui-testing/pages/dashboardPages/dashboard-panel-actions.js index 49d405eaea..4ae796ebde 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-panel-actions.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-panel-actions.js @@ -660,17 +660,53 @@ export default class DashboardactionPage { await this.page.waitForTimeout(1000); } - // Legend is canvas-painted, so read series names via `_vnode` (`__vueParentComponent` is dev-only). async getChartSeriesNames(scopeSelector = '[data-test="chart-renderer"]') { const names = (await this.getChartSeries(scopeSelector)).map((s) => s.name).filter(Boolean); return [...new Set(names)]; } async getChartSeries(scopeSelector = '[data-test="chart-renderer"]') { + const charts = await this.getChartOptionSnapshots(scopeSelector); + return charts.flatMap((chart) => + chart.series.map((s) => ({ + name: s.name, + values: s.points.map((point) => + Array.isArray(point) ? point[point.length - 1] : point, + ), + })), + ); + } + + /** + * Value-axis bounds and stacked series of the first rendered chart, read from the live ECharts option. + * @param {"yAxis"|"xAxis"} valueAxis - the axis that carries values (xAxis for h-stacked) + * @param {string} scopeSelector + * @returns {Promise<{min: number|null, max: number|null, series: Array<{name: string, stack: string|null, points: Array}>}|null>} + */ + async getChartValueAxisFit(valueAxis = "yAxis", scopeSelector = '[data-test="chart-renderer"]') { + const [chart] = await this.getChartOptionSnapshots(scopeSelector); + if (!chart) return null; + const bounds = chart[valueAxis]?.[0] ?? {}; + return { min: bounds.min ?? null, max: bounds.max ?? null, series: chart.series }; + } + + // Legend and axes are canvas-painted, so read the live option via `_vnode` (`__vueParentComponent` is dev-only). + async getChartOptionSnapshots(scopeSelector = '[data-test="chart-renderer"]') { return this.page.evaluate((scopeSel) => { const scopes = Array.from(document.querySelectorAll(scopeSel)); - const series = []; + const charts = []; const visited = new Set(); + const numberOrNull = (value) => (typeof value === "number" ? value : null); + const axesOf = (axis) => + (Array.isArray(axis) ? axis : axis ? [axis] : []).map((a) => ({ + type: a?.type ?? null, + min: numberOrNull(a?.min), + max: numberOrNull(a?.max), + })); + const pointOf = (point) => { + if (Array.isArray(point)) return point.map((v) => (v instanceof Date ? v.getTime() : v)); + return point !== null && typeof point === "object" ? point.value : point; + }; const walk = (node, depth) => { if (!node || typeof node !== "object" || depth > 400 || visited.has(node)) return; visited.add(node); @@ -691,12 +727,16 @@ export default class DashboardactionPage { scopes.some((scope) => scope.contains(host)) ) { try { - for (const s of chart.getOption()?.series ?? []) { - const values = (s?.data ?? []).map((point) => - Array.isArray(point) ? point[point.length - 1] : (point?.value ?? point), - ); - series.push({ name: s?.name ? String(s.name) : "", values }); - } + const option = chart.getOption() ?? {}; + charts.push({ + xAxis: axesOf(option.xAxis), + yAxis: axesOf(option.yAxis), + series: (option.series ?? []).map((s) => ({ + name: s?.name ? String(s.name) : "", + stack: s?.stack === undefined || s?.stack === null ? null : String(s.stack), + points: (s?.data ?? []).map(pointOf), + })), + }); } catch { // A disposed instance throws; it draws nothing anyway. } @@ -707,7 +747,7 @@ export default class DashboardactionPage { if (Array.isArray(node.children)) for (const c of node.children) walk(c, depth + 1); }; walk(document.querySelector("#app")?._vnode, 0); - return series; + return charts; }, scopeSelector); } diff --git a/tests/ui-testing/pages/dashboardPages/dashboard-panel-configs.js b/tests/ui-testing/pages/dashboardPages/dashboard-panel-configs.js index 47dee3b6dc..37ccf9c776 100644 --- a/tests/ui-testing/pages/dashboardPages/dashboard-panel-configs.js +++ b/tests/ui-testing/pages/dashboardPages/dashboard-panel-configs.js @@ -43,6 +43,8 @@ export default class DashboardPanelConfigs { ); this.yAxisMin = page.locator('[data-test="dashboard-config-y_axis_min"]'); this.yAxisMax = page.locator('[data-test="dashboard-config-y_axis_max"]'); + this.yAxisMinField = this.yAxisMin.locator('[data-test$="-field"]'); + this.yAxisMaxField = this.yAxisMax.locator('[data-test$="-field"]'); this.valuePosition = page.locator( '[data-test="dashboard-config-label-position"]' ); diff --git a/tests/ui-testing/pages/logsPages/logsCrossLinkPage.js b/tests/ui-testing/pages/logsPages/logsCrossLinkPage.js new file mode 100644 index 0000000000..6242489bfc --- /dev/null +++ b/tests/ui-testing/pages/logsPages/logsCrossLinkPage.js @@ -0,0 +1,102 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Page object for org-level cross-links as they reach the log-detail drawer. + +import { expect } from '@playwright/test'; +const testLogger = require('../../playwright-tests/utils/test-logger.js'); +const { getAuthHeaders, getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js'); + +export class LogsCrossLinkPage { + constructor(page) { + this.page = page; + + this.detailDialog = '[data-test="logs-search-result-detail-dialog"]'; + this.detailTableTab = '[data-test="log-detail-table-tab"]'; + this.detailTableContent = '[data-test="log-detail-table-content"]'; + this.fieldMenuTrigger = (field) => `[data-test="log-details-include-exclude-field-btn-${field}"]`; + this.fieldValue = (field) => `[data-test="log-detail-${field}-value"]`; + this.crossLinkItem = (name) => `[data-test="log-details-cross-link-${name}"]`; + this.anyCrossLinkItem = '[data-test^="log-details-cross-link-"]'; + } + + // ===== ORG SETTINGS (POST /settings replaces the whole cross_links array) ===== + + async readOrgCrossLinks() { + const url = `${process.env.ZO_BASE_URL}/api/${getOrgIdentifier()}/settings`; + const response = await this.page.request.get(url, { headers: getAuthHeaders() }); + expect(response.status(), 'could not read org settings').toBe(200); + const body = await response.json(); + return body?.data?.cross_links ?? []; + } + + async writeOrgCrossLinks(links) { + const url = `${process.env.ZO_BASE_URL}/api/${getOrgIdentifier()}/settings`; + const response = await this.page.request.post(url, { + headers: getAuthHeaders(), + data: { cross_links: links }, + }); + expect(response.status(), 'could not write org settings').toBe(200); + testLogger.debug('Org cross_links written', { count: links.length }); + } + + // ===== DRAWER ===== + + async openFieldTableTab() { + await this.page.locator(this.detailDialog).locator(this.detailTableTab).click(); + await expect(this.page.locator(this.detailTableContent)).toBeVisible({ timeout: 15000 }); + } + + async openFieldMenu(field) { + const trigger = this.page.locator(this.fieldMenuTrigger(field)).first(); + await trigger.scrollIntoViewIfNeeded().catch(() => {}); + await trigger.click(); + } + + + + async expectCrossLinkOffered(name) { + await expect(this.page.locator(this.crossLinkItem(name))).toBeVisible({ timeout: 10000 }); + } + + async readFieldValue(field) { + const cell = this.page.locator(this.fieldValue(field)).first(); + await expect(cell).toBeVisible({ timeout: 15000 }); + return (await cell.innerText()).trim(); + } + + /** + * Clicks the cross-link item and returns the page window.open actually created. + * The item passes `noopener`, so the new tab arrives on the context, not as a popup + * of this page. + */ + async openCrossLinkAndReadUrl(name) { + const context = this.page.context(); + const [opened] = await Promise.all([ + context.waitForEvent('page', { timeout: 20000 }), + this.page.locator(this.crossLinkItem(name)).first().click(), + ]); + await expect + .poll(() => opened.url(), { + timeout: 15000, + message: 'the opened tab never committed a URL', + }) + .not.toBe('about:blank'); + const url = opened.url(); + await opened.close(); + testLogger.info('Cross-link opened a real tab', { url }); + return url; + } +} diff --git a/tests/ui-testing/pages/logsPages/logsFieldValuesPage.js b/tests/ui-testing/pages/logsPages/logsFieldValuesPage.js new file mode 100644 index 0000000000..c8740c4d76 --- /dev/null +++ b/tests/ui-testing/pages/logsPages/logsFieldValuesPage.js @@ -0,0 +1,106 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { expect } from '@playwright/test'; + +export class LogsFieldValuesPage { + constructor(page) { + this.page = page; + + this.valuesPanel = '[data-test="field-values-panel-container"]'; + // OSearchInput renders no data-test of its own, so the wrapper class is the hook. + this.valueSearchInput = `${this.valuesPanel} .value-search-input-wrap input`; + } + + expandButton(field) { + return `[data-test="log-search-expand-${field}-field-btn"]`; + } + + loadMoreButton(field) { + return `[data-test="log-search-subfield-load-more-${field}"]`; + } + + valueRowPrefix(field) { + return `logs-search-subfield-add-${field}-`; + } + + async openLogs(org, stream) { + const url = + `/web/logs?org_identifier=${org}&stream=${stream}&stream_type=logs` + + '&period=15m&refresh=0&sql_mode=false&quick_mode=false&show_histogram=true'; + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); + } + + async expandField(field) { + const button = this.page.locator(this.expandButton(field)); + await button.waitFor({ state: 'visible', timeout: 60000 }); + await button.click(); + await this.page + .locator(this.valuesPanel) + .first() + .waitFor({ state: 'visible', timeout: 30000 }); + } + + /** The distinct value keys currently rendered for `field`, in display order. */ + async valueKeys(field) { + const prefix = this.valueRowPrefix(field); + return this.page + .locator(`[data-test^="${prefix}"]`) + .evaluateAll( + (elements, len) => elements.map((el) => el.getAttribute('data-test').slice(len)), + prefix.length, + ); + } + + async waitForValues(field, timeout = 60000) { + await expect.poll(async () => (await this.valueKeys(field)).length, { timeout }).toBeGreaterThan(0); + return this.valueKeys(field); + } + + async expectLoadMoreVisible(field) { + await expect(this.page.locator(this.loadMoreButton(field))).toBeVisible({ timeout: 30000 }); + } + + async clickLoadMore(field) { + const button = this.page.locator(this.loadMoreButton(field)); + await button.waitFor({ state: 'visible', timeout: 30000 }); + await button.click(); + } + + async waitForMoreThan(field, count, timeout = 60000) { + await expect.poll(async () => (await this.valueKeys(field)).length, { timeout }).toBeGreaterThan(count); + return this.valueKeys(field); + } + + async searchValues(term) { + const input = this.page.locator(this.valueSearchInput).first(); + await input.waitFor({ state: 'visible', timeout: 30000 }); + await input.fill(term); + } + + /** Polls until every rendered key contains `term` and at least one is rendered. */ + async waitForValuesMatching(field, term, timeout = 60000) { + await expect + .poll( + async () => { + const keys = await this.valueKeys(field); + return keys.length > 0 && keys.every((key) => key.includes(term)); + }, + { timeout }, + ) + .toBe(true); + return this.valueKeys(field); + } +} diff --git a/tests/ui-testing/pages/logsPages/logsResultsToolbarPage.js b/tests/ui-testing/pages/logsPages/logsResultsToolbarPage.js new file mode 100644 index 0000000000..798eecdbb3 --- /dev/null +++ b/tests/ui-testing/pages/logsPages/logsResultsToolbarPage.js @@ -0,0 +1,65 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Page object for the results-header actions and the overflow menu they collapse into +// below SearchResult.vue's measured 700px container width. + +import { expect } from '@playwright/test'; +const testLogger = require('../../playwright-tests/utils/test-logger.js'); + +export class LogsResultsToolbarPage { + constructor(page) { + this.page = page; + + this.wrapContentKey = 'wrapContent'; + this.inlineWrapButton = '[data-test="logs-search-result-wrap-table-content-btn"]'; + this.overflowMenuButton = '[data-test="logs-result-actions-menu-btn"]'; + this.overflowWrapItem = '[data-test="logs-result-wrap-menu-item"]'; + this.overflowRefreshItem = '[data-test="logs-result-refresh-menu-item"]'; + } + + /** + * Resizes, then waits for the header to settle: containerWidth comes from a + * ResizeObserver, so reading the toolbar at once races the recompute. + */ + async setViewportWidthAndSettle(width, { expectOverflow }) { + await this.page.setViewportSize({ width, height: 1024 }); + await this.page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))), + ), + ); + const appears = expectOverflow ? this.overflowMenuButton : this.inlineWrapButton; + const disappears = expectOverflow ? this.inlineWrapButton : this.overflowMenuButton; + await expect(this.page.locator(appears)).toBeVisible({ timeout: 20000 }); + await expect(this.page.locator(disappears)).toHaveCount(0); + testLogger.debug('Results header settled', { width, expectOverflow }); + } + + async openOverflowMenu() { + await this.page.locator(this.overflowMenuButton).click(); + await expect(this.page.locator(this.overflowRefreshItem)).toBeVisible({ timeout: 10000 }); + } + + async clickOverflowWrapItem() { + await this.page.locator(this.overflowWrapItem).click(); + await expect(this.page.locator(this.overflowRefreshItem)).toHaveCount(0); + } + + async readStoredWrapContent() { + return await this.page.evaluate((key) => localStorage.getItem(key), this.wrapContentKey); + } +} diff --git a/tests/ui-testing/pages/logsPages/logsSavedFunctionPage.js b/tests/ui-testing/pages/logsPages/logsSavedFunctionPage.js new file mode 100644 index 0000000000..4a735a84be --- /dev/null +++ b/tests/ui-testing/pages/logsPages/logsSavedFunctionPage.js @@ -0,0 +1,102 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { expect } from '@playwright/test'; + +export class LogsSavedFunctionPage { + constructor(page) { + this.page = page; + + this.functionEditor = '[data-test="logs-vrl-function-editor"]'; + this.saveFunctionButton = '[data-test="logs-search-bar-save-function-btn"]'; + this.functionDropdown = '[data-test="logs-search-bar-function-dropdown"]'; + + this.savedFunctionDialog = '[data-test="search-bar-store-state-saved-function-dialog"]'; + this.updateConfirmDialog = '[data-test="search-bar-function-update-confirm-dialog"]'; + this.dialogPrimaryButton = '[data-test="o-dialog-primary-btn"]'; + + this.nameInput = '[data-test="saved-function-name-input-field"]'; + this.updateModeButton = '[data-test="saved-function-action-toggle"] button[value="update"]'; + this.nameSelectTrigger = '[data-test="saved-function-name-select-trigger"]'; + } + + savedFunctionItem(name) { + return `[data-test="logs-search-saved-function-${name}"]`; + } + + nameSelectOption(name) { + return `[data-test="saved-function-name-select-option"][data-test-value="${name}"]`; + } + + async openLogs(org, stream) { + const url = + `/web/logs?org_identifier=${org}&stream=${stream}&stream_type=logs` + + '&period=15m&refresh=0&sql_mode=false&quick_mode=false&show_histogram=true&fn_editor=true'; + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); + await this.page + .locator(`${this.functionEditor} .monaco-editor`) + .first() + .waitFor({ state: 'visible', timeout: 60000 }); + } + + async setFunctionBody(text) { + const editor = this.page.locator(`${this.functionEditor} .monaco-editor`).first(); + await editor.click(); + await this.page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A'); + // Typed, not filled: SearchBar reads tempFunctionContent from Monaco's change events, which a direct value write never fires. + const textarea = this.page.locator(`${this.functionEditor} .monaco-editor textarea`); + await textarea.pressSequentially(text); + await expect.poll(() => this.functionBody(), { timeout: 15000 }).toContain(text); + // CodeQueryEditor holds the edit for its 500ms debounce; blur flushes it to tempFunctionContent, which the save dialog gates on. + await textarea.blur(); + } + + async functionBody() { + const text = await this.page.locator(`${this.functionEditor} .view-lines`).first().innerText(); + return text.replace(/\u00a0/g, ' ').trim(); + } + + async openSaveDialog() { + await this.page.locator(this.saveFunctionButton).click(); + await this.page.locator(this.savedFunctionDialog).waitFor({ state: 'visible', timeout: 30000 }); + } + + async submitDialog(dialog) { + await this.page.locator(`${dialog} ${this.dialogPrimaryButton}`).click(); + } + + async createFunction(name) { + await this.openSaveDialog(); + await this.page.locator(this.nameInput).fill(name); + await this.submitDialog(this.savedFunctionDialog); + await this.page.locator(this.savedFunctionDialog).waitFor({ state: 'hidden', timeout: 30000 }); + } + + async overwriteFunction(name) { + await this.openSaveDialog(); + await this.page.locator(this.updateModeButton).click(); + await this.page.locator(this.nameSelectTrigger).click(); + await this.page.locator(this.nameSelectOption(name)).click(); + await this.submitDialog(this.savedFunctionDialog); + await this.page.locator(this.updateConfirmDialog).waitFor({ state: 'visible', timeout: 30000 }); + await this.submitDialog(this.updateConfirmDialog); + await this.page.locator(this.savedFunctionDialog).waitFor({ state: 'hidden', timeout: 30000 }); + } + + async applySavedFunction(name) { + await this.page.locator(this.functionDropdown).click(); + await this.page.locator(this.savedFunctionItem(name)).click(); + } +} diff --git a/tests/ui-testing/playwright-tests/Dashboards/dashboard-stacked-axis-bounds.spec.js b/tests/ui-testing/playwright-tests/Dashboards/dashboard-stacked-axis-bounds.spec.js new file mode 100644 index 0000000000..5e8c611b7b --- /dev/null +++ b/tests/ui-testing/playwright-tests/Dashboards/dashboard-stacked-axis-bounds.spec.js @@ -0,0 +1,213 @@ +const { + test, + expect, + navigateToBase, +} = require("../utils/enhanced-baseFixtures.js"); +import PageManager from "../../pages/page-manager"; +import { ingestion } from "./utils/dashIngestion.js"; +import { cleanupTestDashboard } from "./utils/dashCreation.js"; +import { + generateDashboardName, + setupStackedPanelWithConfig, + setupBarPanelWithBreakdownAndConfig, + setupPromQLStackedPanelWithConfig, + reopenPanelConfig, +} from "./utils/configPanelHelpers.js"; +import { stackExtent, tallestPoint, waitForValueAxisFit } from "./utils/stackedAxisHelpers.js"; +const testLogger = require("../utils/test-logger.js"); +const { ensureMetricsIngested } = require("../utils/shared-metrics-setup.js"); + +// Below any real stack of the ingested counts, so a kept bound would visibly clip the chart. +const CLIPPING_MAX = "1"; +const ROOMY_MAX = "100000000"; +const maxApplied = (fit) => fit.max !== null; +const minApplied = (fit) => fit.min !== null; + +test.describe.configure({ mode: "parallel" }); + +test.describe("Stacked panels — user y-axis bounds never clip a stack (SQL)", () => { + test.beforeEach(async ({ page }) => { + await navigateToBase(page); + await ingestion(page); + }); + + test( + "stacked: y max below the tallest stack widens to the stack sum; saved value stays as typed", + { tag: ["@dashboardStackedAxisBounds", "@dashboards", "@P0", "@all"] }, + async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await setupStackedPanelWithConfig(page, pm, dashboardName); + await pm.dashboardPanelConfigs.Y_AxisMax(CLIPPING_MAX); + await pm.dashboardPanelActions.applyDashboardBtn(); + await pm.dashboardPanelActions.waitForChartToRender(); + + const fit = await waitForValueAxisFit(pm, expect, { ready: maxApplied }); + const { hi } = stackExtent(fit.series); + testLogger.info("Stacked fit in editor", { max: fit.max, tallestStack: hi }); + expect(hi).toBeGreaterThan(Number(CLIPPING_MAX)); + expect(fit.max).toBeCloseTo(hi, 6); + + await pm.dashboardPanelActions.savePanel(); + const savedFit = await waitForValueAxisFit(pm, expect, { ready: maxApplied }); + expect(savedFit.max).toBeCloseTo(stackExtent(savedFit.series).hi, 6); + testLogger.info("Saved dashboard panel keeps the full stacks", { max: savedFit.max }); + + await reopenPanelConfig(page, pm); + await expect(pm.dashboardPanelConfigs.yAxisMaxField).toHaveValue(CLIPPING_MAX); + await pm.dashboardPanelActions.savePanel(); + await cleanupTestDashboard(page, pm, dashboardName); + }, + ); + + test( + "stacked: y max above every stack is kept as set", + { tag: ["@dashboardStackedAxisBounds", "@dashboards", "@P1", "@all"] }, + async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await setupStackedPanelWithConfig(page, pm, dashboardName); + await pm.dashboardPanelConfigs.Y_AxisMax(ROOMY_MAX); + await pm.dashboardPanelActions.applyDashboardBtn(); + await pm.dashboardPanelActions.waitForChartToRender(); + + const fit = await waitForValueAxisFit(pm, expect, { ready: maxApplied }); + const { hi } = stackExtent(fit.series); + testLogger.info("Roomy max", { max: fit.max, tallestStack: hi }); + expect(hi).toBeLessThan(Number(ROOMY_MAX)); + expect(fit.max).toBe(Number(ROOMY_MAX)); + + await pm.dashboardPanelActions.savePanel(); + await cleanupTestDashboard(page, pm, dashboardName); + }, + ); + + test( + "stacked: y min above zero widens down to the bar baseline; an unset max stays automatic", + { tag: ["@dashboardStackedAxisBounds", "@dashboards", "@P1", "@all"] }, + async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await setupStackedPanelWithConfig(page, pm, dashboardName); + await pm.dashboardPanelConfigs.Y_AxisMin("5"); + await pm.dashboardPanelActions.applyDashboardBtn(); + await pm.dashboardPanelActions.waitForChartToRender(); + + const fit = await waitForValueAxisFit(pm, expect, { ready: minApplied }); + testLogger.info("Stacked min fit", { min: fit.min, max: fit.max }); + expect(fit.min).toBe(0); + expect(fit.max).toBeNull(); + + await pm.dashboardPanelActions.savePanel(); + await cleanupTestDashboard(page, pm, dashboardName); + }, + ); + + test( + "h-stacked: the value (x) axis max widens to the stack sum", + { tag: ["@dashboardStackedAxisBounds", "@dashboards", "@P1", "@all"] }, + async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await setupStackedPanelWithConfig(page, pm, dashboardName, { chartType: "h-stacked" }); + await pm.dashboardPanelConfigs.Y_AxisMax(CLIPPING_MAX); + await pm.dashboardPanelActions.applyDashboardBtn(); + await pm.dashboardPanelActions.waitForChartToRender(); + + const fit = await waitForValueAxisFit(pm, expect, { valueAxis: "xAxis", ready: maxApplied }); + const { hi } = stackExtent(fit.series); + testLogger.info("h-stacked fit", { max: fit.max, tallestStack: hi }); + expect(hi).toBeGreaterThan(Number(CLIPPING_MAX)); + expect(fit.max).toBeCloseTo(hi, 6); + + await pm.dashboardPanelActions.savePanel(); + await cleanupTestDashboard(page, pm, dashboardName); + }, + ); + + test( + "area-stacked: y max below the tallest stack widens to the stack sum", + { tag: ["@dashboardStackedAxisBounds", "@dashboards", "@P1", "@all"] }, + async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await setupStackedPanelWithConfig(page, pm, dashboardName, { chartType: "area-stacked" }); + await pm.dashboardPanelConfigs.Y_AxisMax(CLIPPING_MAX); + await pm.dashboardPanelActions.applyDashboardBtn(); + await pm.dashboardPanelActions.waitForChartToRender(); + + const fit = await waitForValueAxisFit(pm, expect, { ready: maxApplied }); + const { hi } = stackExtent(fit.series); + testLogger.info("area-stacked fit", { max: fit.max, tallestStack: hi }); + expect(hi).toBeGreaterThan(Number(CLIPPING_MAX)); + expect(fit.max).toBeCloseTo(hi, 6); + + await pm.dashboardPanelActions.savePanel(); + await cleanupTestDashboard(page, pm, dashboardName); + }, + ); + + test( + "bar (not stacked): y max widens only to the tallest single bar, never to a stack sum", + { tag: ["@dashboardStackedAxisBounds", "@dashboards", "@P2", "@all"] }, + async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await setupBarPanelWithBreakdownAndConfig(page, pm, dashboardName); + await pm.dashboardPanelConfigs.Y_AxisMax(CLIPPING_MAX); + await pm.dashboardPanelActions.applyDashboardBtn(); + await pm.dashboardPanelActions.waitForChartToRender(); + + const fit = await waitForValueAxisFit(pm, expect, { ready: maxApplied }); + const tallestBar = tallestPoint(fit.series); + // Summing every series per bucket is what the bound would be if bars were wrongly treated as one stack. + const asOneStack = stackExtent(fit.series.map((s) => ({ ...s, stack: "all" }))).hi; + testLogger.info("Bar fits its tallest bar", { max: fit.max, tallestBar, asOneStack }); + expect(tallestBar).toBeGreaterThan(Number(CLIPPING_MAX)); + expect(asOneStack).toBeGreaterThan(tallestBar); + expect(fit.max).toBe(tallestBar); + + await pm.dashboardPanelActions.savePanel(); + await cleanupTestDashboard(page, pm, dashboardName); + }, + ); +}); + +test.describe("Stacked panels — user y-axis bounds never clip a stack (PromQL)", () => { + test.beforeAll(async () => { + await ensureMetricsIngested(); + }); + + test.beforeEach(async ({ page }) => { + await navigateToBase(page); + }); + + test( + "PromQL stacked: y max below the tallest stack widens to the stack sum", + { tag: ["@dashboardStackedAxisBounds", "@dashboards", "@promql", "@P1", "@all"] }, + async ({ page }) => { + const pm = new PageManager(page); + const dashboardName = generateDashboardName(); + + await setupPromQLStackedPanelWithConfig(page, pm, dashboardName); + // cpu_usage is a percentage, so a sub-1 max always cuts into its stack. + await pm.dashboardPanelConfigs.Y_AxisMax("0.001"); + await pm.dashboardPanelActions.applyAndWaitForPromQLQuery(); + + const fit = await waitForValueAxisFit(pm, expect, { ready: maxApplied }); + const { hi } = stackExtent(fit.series); + testLogger.info("PromQL stacked fit", { max: fit.max, tallestStack: hi }); + expect(hi).toBeGreaterThan(0.001); + expect(fit.max).toBeCloseTo(hi, 6); + + await pm.dashboardPanelActions.savePanel(); + await cleanupTestDashboard(page, pm, dashboardName); + }, + ); +}); diff --git a/tests/ui-testing/playwright-tests/Dashboards/utils/configPanelHelpers.js b/tests/ui-testing/playwright-tests/Dashboards/utils/configPanelHelpers.js index f6d7a5bfb8..820c40e13a 100644 --- a/tests/ui-testing/playwright-tests/Dashboards/utils/configPanelHelpers.js +++ b/tests/ui-testing/playwright-tests/Dashboards/utils/configPanelHelpers.js @@ -168,6 +168,22 @@ export async function setupBarPanelWithBreakdownAndConfig(page, pm, dashboardNam testLogger.info("Bar panel with breakdown and config ready", { dashboardName, panelName }); } +/** + * Stacked-family panel (stacked / h-stacked / area-stacked) broken down by host so each bucket stacks several series — config sidebar opened and ready. + */ +export async function setupStackedPanelWithConfig(page, pm, dashboardName, { + chartType = "stacked", + panelName = "Test Panel", +} = {}) { + await buildPanel(page, pm, dashboardName, { + chartType, + panelName, + breakdownField: "kubernetes_host", + }); + await pm.dashboardPanelConfigs.openConfigPanel(); + testLogger.info("Stacked panel with breakdown and config ready", { chartType, dashboardName, panelName }); +} + /** * Line chart panel — config sidebar opened and ready. */ @@ -396,6 +412,16 @@ export async function setupPromQLPanelWithConfig(page, pm, dashboardName, panelN testLogger.info("PromQL line panel with config ready", { dashboardName, panelName }); } +/** + * PromQL stacked panel over the seeded cpu_usage metric — config sidebar opened and ready. + * Caller's test.beforeAll must call ensureMetricsIngested(). + */ +export async function setupPromQLStackedPanelWithConfig(page, pm, dashboardName, panelName = "Test Panel") { + await buildPromQLPanel(page, pm, dashboardName, { chartType: "stacked", panelName, query: "cpu_usage" }); + await pm.dashboardPanelConfigs.openConfigPanel(); + testLogger.info("PromQL stacked panel with config ready", { dashboardName, panelName }); +} + /** * PromQL heatmap panel — config sidebar opened and ready. * Caller's test.beforeAll must call ensureMetricsIngested(). diff --git a/tests/ui-testing/playwright-tests/Dashboards/utils/stackedAxisHelpers.js b/tests/ui-testing/playwright-tests/Dashboards/utils/stackedAxisHelpers.js new file mode 100644 index 0000000000..2a7748c783 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Dashboards/utils/stackedAxisHelpers.js @@ -0,0 +1,76 @@ +/** + * Helpers for asserting how stacked panels fit a user-set value-axis min/max to the rendered stacks. + */ + +/** + * Tallest positive and deepest negative stack end across every bucket; positives and negatives stack apart. + * + * @param {Array<{name: string, stack: string|null, points: Array}>} series + * @returns {{hi: number, lo: number}} stack extent, anchored at 0 like a bar baseline + */ +export function stackExtent(series) { + const sums = new Map(); + let hi = 0; + let lo = 0; + series.forEach((s, seriesIndex) => { + // Unnamed series carry mark lines and annotations, not stacked data. + if (!s.name) return; + const group = s.stack ?? `series:${seriesIndex}`; + s.points.forEach((point, index) => { + // A time-axis point is [time, value]; a category point sits at its category's index. + const [bucket, raw] = Array.isArray(point) ? [point[0], point[1]] : [index, point]; + const value = Number(raw); + if (raw === null || raw === undefined || raw === "" || !Number.isFinite(value)) return; + const key = `${group}|${value < 0 ? "-" : "+"}|${bucket}`; + const sum = (sums.get(key) ?? 0) + value; + sums.set(key, sum); + if (value < 0) lo = Math.min(lo, sum); + else hi = Math.max(hi, sum); + }); + }); + return { hi, lo }; +} + +/** + * Largest single plotted value across all named series, ignoring how they stack. + * + * @param {Array<{name: string, points: Array}>} series + * @returns {number} + */ +export function tallestPoint(series) { + return Math.max( + 0, + ...series + .filter((s) => s.name) + .flatMap((s) => s.points.map((p) => Number(Array.isArray(p) ? p[1] : p))) + .filter(Number.isFinite), + ); +} + +/** + * Polls the rendered chart until its series carry data and `ready` accepts the fit, then returns that fit. + * + * @param {object} pm - PageManager instance + * @param {Function} expect - Playwright expect + * @param {object} [options] + * @param {"yAxis"|"xAxis"} [options.valueAxis] + * @param {(fit: object) => boolean} [options.ready] - e.g. wait until the applied max reached the option + * @param {string} [options.scopeSelector] + */ +export async function waitForValueAxisFit(pm, expect, { + valueAxis = "yAxis", + ready = () => true, + scopeSelector, +} = {}) { + let fit = null; + await expect + .poll( + async () => { + fit = await pm.dashboardPanelActions.getChartValueAxisFit(valueAxis, scopeSelector); + return Boolean(fit?.series.some((s) => s.name && s.points.length > 0) && ready(fit)); + }, + { timeout: 30000, intervals: [500, 1000, 2000] }, + ) + .toBe(true); + return fit; +} diff --git a/tests/ui-testing/playwright-tests/Logs/logsCrossLinks.spec.js b/tests/ui-testing/playwright-tests/Logs/logsCrossLinks.spec.js new file mode 100644 index 0000000000..751097e605 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Logs/logsCrossLinks.spec.js @@ -0,0 +1,115 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Cross-links reach the drawer only if the server returns stream_links/org_links for +// the queried fields, which no unit test can stand in for. + +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const PageManager = require('../../pages/page-manager.js'); +const logData = require('../../fixtures/log.json'); +const { ingestTestData: ingestion } = require('../utils/data-ingestion.js'); +const { getAuthHeaders, getOrgIdentifier } = require('../utils/cloud-auth.js'); +const { LogsCrossLinkPage } = require('../../pages/logsPages/logsCrossLinkPage.js'); + +const STREAM = 'e2e_automate'; +const LINKED_FIELD = 'kubernetes_namespace_name'; + +// The org-level cross_links array is shared, so no two workers may hold a snapshot of it. +test.describe.configure({ mode: 'serial' }); + +test.describe('Logs cross-links', () => { + let pm; + let crossLinkPage; + let linkName; + let savedCrossLinks; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + crossLinkPage = new LogsCrossLinkPage(page); + + const configResponse = await page.request.get( + `${process.env.ZO_BASE_URL}/api/${getOrgIdentifier()}/config`, + { headers: getAuthHeaders() }, + ); + const zoConfig = await configResponse.json(); + // Server env, not org settings: a test cannot turn this on, so off means misconfigured. + expect( + zoConfig?.enable_cross_linking, + 'ZO_ENABLE_CROSS_LINKING is off on this environment, so no cross-link is ever offered. ' + + 'It is set true by .github/workflows/playwright.yml:63 and ' + + 'playwright_bugfix_regression.yml:43 — set the env var on the environment under test. ' + + 'Do not skip or delete this spec.', + ).toBeTruthy(); + + linkName = `e2e_crosslink_${Date.now()}`; + savedCrossLinks = await crossLinkPage.readOrgCrossLinks(); + await crossLinkPage.writeOrgCrossLinks([ + ...savedCrossLinks, + { + name: linkName, + // A cheap same-origin target: the assertion is the resolved URL, not what it renders. + url: + `${process.env.ZO_BASE_URL}/config?xl_field=\${field.__name}` + + '&xl_value=${field.__value}&xl_from=${start_time}&xl_to=${end_time}&xl_query=${query}', + fields: [{ name: LINKED_FIELD }], + }, + ]); + + await ingestion(page); + await page.goto(`${logData.logsUrl}?org_identifier=${getOrgIdentifier()}`); + await pm.logsPage.selectStream(STREAM); + await pm.logsPage.clickDateTimeButton(); + await pm.logsPage.clickRelative15MinButton(); + await pm.logsPage.clickSearchBarRefreshButton(); + await pm.logsPage.expectResultsGridSettledWithRows(); + await pm.logsPage.openLogDetailSidebar(); + await crossLinkPage.openFieldTableTab(); + }); + + test.afterEach(async () => { + // Restore the exact array that was there; a POST replaces cross_links wholesale. + if (savedCrossLinks) await crossLinkPage.writeOrgCrossLinks(savedCrossLinks); + }); + + test('a configured cross-link opens the resolved URL in a real tab', { + tag: ['@crossLinks', '@logs', '@P1', '@all'], + }, async () => { + const fieldValue = await crossLinkPage.readFieldValue(LINKED_FIELD); + expect(fieldValue, 'the linked field rendered no value to substitute').not.toBe(''); + + await crossLinkPage.openFieldMenu(LINKED_FIELD); + await crossLinkPage.expectCrossLinkOffered(linkName); + + const openedUrl = await crossLinkPage.openCrossLinkAndReadUrl(linkName); + // A literal space would mean the SQL was spliced in unencoded and the href is invalid. + expect(openedUrl, 'the resolved URL was not percent-encoded').not.toMatch(/\s/); + + const params = new URL(openedUrl).searchParams; + expect(params.get('xl_field')).toBe(LINKED_FIELD); + expect(params.get('xl_value')).toBe(fieldValue); + expect(params.get('xl_query')).toContain(STREAM); + + const from = Number(params.get('xl_from')); + const to = Number(params.get('xl_to')); + expect(String(from), 'start_time was not substituted as microseconds').toMatch(/^\d{16}$/); + expect(String(to), 'end_time was not substituted as microseconds').toMatch(/^\d{16}$/); + expect(from).toBeLessThan(to); + + testLogger.info('Cross-link resolved and opened with the row value and search window'); + }); +}); diff --git a/tests/ui-testing/playwright-tests/Logs/logsFieldValuePaging.spec.js b/tests/ui-testing/playwright-tests/Logs/logsFieldValuePaging.spec.js new file mode 100644 index 0000000000..4c3e3c1354 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Logs/logsFieldValuePaging.spec.js @@ -0,0 +1,115 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const { ingestRows, waitForSearchable } = require('../utils/auto-run-helpers.js'); +const { getAuthHeaders, getOrgIdentifier } = require('../utils/cloud-auth.js'); +const { LogsFieldValuesPage } = require('../../pages/logsPages/logsFieldValuesPage.js'); + +const ORG = getOrgIdentifier(); +const STREAM = `fvpaging${Date.now()}`; +const FIELD = 'host'; +// Matches db-10..db-12 only, which no page of this fixture ever rendered, so the panel's local cache filter cannot produce a hit. +const SEARCH_TERM = 'db-1'; + +function apiBase() { + const url = process.env.ZO_BASE_URL; + return url.endsWith('/') ? url.slice(0, -1) : url; +} + +// Distinct per-value counts pin the backend rank order, so page 2 is the next values down and never a reshuffle of page 1. +function fixtureRows() { + const now = Date.now() * 1000; + const rows = []; + let offset = 0; + const push = (host, count) => { + for (let i = 0; i < count; i++) { + rows.push({ _timestamp: now - 5 * 60e6 + offset++ * 1000, host, body: `probe ${host} ${i}` }); + } + }; + for (let i = 1; i <= 12; i++) push(`web-${String(i).padStart(2, '0')}`, 25 - i); + for (let i = 1; i <= 12; i++) push(`db-${String(i).padStart(2, '0')}`, 13 - i); + return rows; +} + +const FIXTURE = fixtureRows(); + +// beforeAll/afterAll run per worker, and this file shares one ingested stream. +test.describe.configure({ mode: 'serial' }); + +test.describe('Logs field-value paging and in-field search', () => { + let fieldValues; + + test.beforeAll(async ({ request }) => { + await ingestRows(request, ORG, STREAM, FIXTURE); + await waitForSearchable(request, ORG, STREAM, FIXTURE.length); + }); + + test.afterAll(async ({ request }) => { + const response = await request.delete(`${apiBase()}/api/${ORG}/streams/${STREAM}?type=logs`, { + headers: getAuthHeaders(), + }); + testLogger.info('Deleted the stream this file created', { + stream: STREAM, + status: response.status(), + }); + }); + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + fieldValues = new LogsFieldValuesPage(page); + await fieldValues.openLogs(ORG, STREAM); + }); + + test('"load more" fetches additional distinct values from the backend', { + tag: ['@fieldValuePaging', '@all', '@logs'], + }, async () => { + await fieldValues.expandField(FIELD); + const firstPage = await fieldValues.waitForValues(FIELD); + await fieldValues.expectLoadMoreVisible(FIELD); + + await fieldValues.clickLoadMore(FIELD); + const afterLoadMore = await fieldValues.waitForMoreThan(FIELD, firstPage.length); + + // A backend ignoring the larger `size` would answer the same page and leave the list unchanged, so the delta is the whole assertion. + const added = afterLoadMore.filter((key) => !firstPage.includes(key)); + expect(added.length).toBeGreaterThan(0); + expect(new Set(afterLoadMore).size).toBe(afterLoadMore.length); + for (const key of firstPage) expect(afterLoadMore).toContain(key); + + testLogger.info('Load more appended distinct values', { + firstPage: firstPage.length, + added: added.length, + }); + }); + + test('in-field value search returns values the first page never held', { + tag: ['@fieldValuePaging', '@all', '@logs'], + }, async () => { + await fieldValues.expandField(FIELD); + const firstPage = await fieldValues.waitForValues(FIELD); + expect(firstPage.some((key) => key.includes(SEARCH_TERM))).toBe(false); + + await fieldValues.searchValues(SEARCH_TERM); + const narrowed = await fieldValues.waitForValuesMatching(FIELD, SEARCH_TERM); + + expect(narrowed.length).toBeGreaterThan(0); + for (const key of narrowed) expect(firstPage).not.toContain(key); + + testLogger.info('In-field search narrowed to server-side matches', { narrowed }); + }); +}); diff --git a/tests/ui-testing/playwright-tests/Logs/logsResultsToolbar.spec.js b/tests/ui-testing/playwright-tests/Logs/logsResultsToolbar.spec.js new file mode 100644 index 0000000000..68bbdd0fc3 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Logs/logsResultsToolbar.spec.js @@ -0,0 +1,80 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// The results-header breakpoint is a measured container width, so only a real viewport +// resize can fire it. + +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const PageManager = require('../../pages/page-manager.js'); +const logData = require('../../fixtures/log.json'); +const { ingestTestData: ingestion } = require('../utils/data-ingestion.js'); +const { getOrgIdentifier } = require('../utils/cloud-auth.js'); +const { LogsResultsToolbarPage } = require('../../pages/logsPages/logsResultsToolbarPage.js'); + +const STREAM = 'e2e_automate'; +const WIDE_VIEWPORT = 1500; +// Below SearchResult.vue's 700px container threshold once the field-list sidebar is paid for. +const NARROW_VIEWPORT = 800; + +test.describe('Logs results header overflow actions', () => { + let pm; + let toolbar; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + toolbar = new LogsResultsToolbarPage(page); + + await ingestion(page); + await page.setViewportSize({ width: WIDE_VIEWPORT, height: 1024 }); + await page.goto(`${logData.logsUrl}?org_identifier=${getOrgIdentifier()}`); + await pm.logsPage.selectStream(STREAM); + await pm.logsPage.clickDateTimeButton(); + await pm.logsPage.clickRelative15MinButton(); + await pm.logsPage.clickSearchBarRefreshButton(); + await pm.logsPage.expectResultsGridSettledWithRows(); + }); + + test('a narrow viewport moves the results actions into a working overflow menu', { + tag: ['@resultsToolbar', '@logs', '@P2', '@all'], + }, async ({ page }) => { + await toolbar.setViewportWidthAndSettle(WIDE_VIEWPORT, { expectOverflow: false }); + const wrapBefore = await toolbar.readStoredWrapContent(); + + let searchCalls = 0; + page.on('request', (request) => { + if (request.url().includes('/_search')) searchCalls += 1; + }); + + await toolbar.setViewportWidthAndSettle(NARROW_VIEWPORT, { expectOverflow: true }); + await toolbar.openOverflowMenu(); + await toolbar.clickOverflowWrapItem(); + + await expect + .poll(() => toolbar.readStoredWrapContent(), { + timeout: 15000, + message: 'the overflow wrap item did not change the stored wrap preference', + }) + .not.toBe(wrapBefore); + // Wrap is a render-only concern; re-querying on it would be a regression. + expect(searchCalls, 'wrapping from the overflow menu issued a search').toBe(0); + + await toolbar.setViewportWidthAndSettle(WIDE_VIEWPORT, { expectOverflow: false }); + + testLogger.info('Actions collapsed below the breakpoint, stayed usable, and came back'); + }); +}); diff --git a/tests/ui-testing/playwright-tests/Logs/logsSavedFunctionOverwrite.spec.js b/tests/ui-testing/playwright-tests/Logs/logsSavedFunctionOverwrite.spec.js new file mode 100644 index 0000000000..0fd6a77e13 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Logs/logsSavedFunctionOverwrite.spec.js @@ -0,0 +1,122 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const { ingestRows, waitForSearchable } = require('../utils/auto-run-helpers.js'); +const { getAuthHeaders, getOrgIdentifier } = require('../utils/cloud-auth.js'); +const { LogsSavedFunctionPage } = require('../../pages/logsPages/logsSavedFunctionPage.js'); + +const ORG = getOrgIdentifier(); +const STAMP = Date.now(); +const STREAM = `fnovr${STAMP}`; +const FUNCTION_NAME = `fnovr${STAMP}`; +// No quotes or brackets: Monaco auto-closes them, which would corrupt what the keystrokes leave in the editor. +const BODY_BEFORE = '.overwrite_marker = 101'; +const BODY_AFTER = '.overwrite_marker = 202'; + +function apiBase() { + const url = process.env.ZO_BASE_URL; + return url.endsWith('/') ? url.slice(0, -1) : url; +} + +function trackFunctionWrites(page) { + const sent = []; + page.on('request', (request) => { + if (/\/api\/[^/]+\/functions(\/|\?|$)/.test(request.url())) { + sent.push({ method: request.method(), url: request.url() }); + } + }); + return { of: (method) => sent.filter((entry) => entry.method === method) }; +} + +async function storedFunction(request, name) { + const response = await request.get( + `${apiBase()}/api/${ORG}/functions?page_num=1&page_size=1000&name=${name}`, + { headers: getAuthHeaders() }, + ); + expect(response.ok(), await response.text()).toBe(true); + const body = await response.json(); + return (body.list || []).filter((entry) => entry.name === name); +} + +// beforeAll/afterAll run per worker, and this file shares one ingested stream plus one saved function. +test.describe.configure({ mode: 'serial' }); + +test.describe('Logs saved-function overwrite', () => { + let savedFunction; + + test.beforeAll(async ({ request }) => { + const now = Date.now() * 1000; + const rows = Array.from({ length: 10 }, (_, i) => ({ + _timestamp: now - 60e6 + i * 1000, + host: `web-${i}`, + body: `overwrite fixture ${i}`, + })); + await ingestRows(request, ORG, STREAM, rows); + await waitForSearchable(request, ORG, STREAM, rows.length); + }); + + test.afterAll(async ({ request }) => { + const fn = await request.delete(`${apiBase()}/api/${ORG}/functions/${FUNCTION_NAME}`, { + headers: getAuthHeaders(), + }); + const stream = await request.delete(`${apiBase()}/api/${ORG}/streams/${STREAM}?type=logs`, { + headers: getAuthHeaders(), + }); + testLogger.info('Deleted exactly what this file created', { + function: [FUNCTION_NAME, fn.status()], + stream: [STREAM, stream.status()], + }); + }); + + test('overwriting a saved function replaces the stored body and survives a reload', { + tag: ['@savedFunctionOverwrite', '@all', '@logs'], + }, async ({ page, request }) => { + testLogger.testStart('saved-function overwrite', __filename); + const writes = trackFunctionWrites(page); + await navigateToBase(page); + savedFunction = new LogsSavedFunctionPage(page); + await savedFunction.openLogs(ORG, STREAM); + + await savedFunction.setFunctionBody(BODY_BEFORE); + await savedFunction.createFunction(FUNCTION_NAME); + await expect + .poll(async () => (await storedFunction(request, FUNCTION_NAME)).length, { timeout: 30000 }) + .toBe(1); + + await savedFunction.setFunctionBody(BODY_AFTER); + await savedFunction.overwriteFunction(FUNCTION_NAME); + + // The PUT is what separates an overwrite from a second create. + await expect.poll(() => writes.of('PUT').length, { timeout: 30000 }).toBeGreaterThan(0); + + const stored = await storedFunction(request, FUNCTION_NAME); + expect(stored).toHaveLength(1); + expect(stored[0].function).toContain(BODY_AFTER); + expect(stored[0].function).not.toContain(BODY_BEFORE); + + await savedFunction.openLogs(ORG, STREAM); + await savedFunction.applySavedFunction(FUNCTION_NAME); + await expect + .poll(async () => savedFunction.functionBody(), { timeout: 30000 }) + .toContain(BODY_AFTER); + expect(await savedFunction.functionBody()).not.toContain(BODY_BEFORE); + + testLogger.info('Overwritten body round-tripped and survived the reload', { + function: FUNCTION_NAME, + }); + }); +}); diff --git a/web/src/components/DateTime.vue b/web/src/components/DateTime.vue index 98b99c6025..858b13e822 100644 --- a/web/src/components/DateTime.vue +++ b/web/src/components/DateTime.vue @@ -364,7 +364,6 @@ along with this program. If not, see . data-test="date-time-apply-btn" variant="primary" size="xs" - class="element-box-shadow" @click="applyAndClose" > {{ t("common.apply") }} diff --git a/web/src/layouts/MainLayout.rum.spec.ts b/web/src/layouts/MainLayout.rum.spec.ts index 850e8c1b3c..46f1be2774 100644 --- a/web/src/layouts/MainLayout.rum.spec.ts +++ b/web/src/layouts/MainLayout.rum.spec.ts @@ -22,6 +22,8 @@ import router from "@/test/unit/helpers/router"; import config from "@/aws-exports"; import { openobserveRum } from "@openobserve/browser-rum"; import { useLocalOrganization } from "@/utils/zincutils"; +import configService from "@/services/config"; +import { initBrowserRum } from "@/utils/rum/initBrowserRum"; vi.mock("@/services/config", () => ({ default: { get_config_full: vi.fn(() => new Promise(() => {})) }, @@ -32,6 +34,8 @@ vi.mock("@/utils/zincutils", async (importOriginal) => ({ invalidateLoginData: vi.fn(), })); +vi.mock("@/utils/rum/initBrowserRum", () => ({ initBrowserRum: vi.fn() })); + vi.mock("@openobserve/browser-rum", () => ({ openobserveRum: { setUser: vi.fn(), @@ -289,6 +293,22 @@ describe("MainLayout — RUM identity and account", () => { }); }); + it("starts RUM from the authenticated full config", async () => { + const rum = { enabled: true, client_token: "token", application_id: "app", site: "rum" }; + vi.mocked(configService.get_config_full).mockResolvedValueOnce({ + data: { version: "v1", rum }, + } as any); + store.state.zoConfig = { ...originalZoConfig, version: "" }; + wrapper = mountMainLayout(); + await flushPromises(); + + store.state.selectedOrganization = { label: "Rum", identifier: "rum-start" }; + await flushPromises(); + + expect(configService.get_config_full).toHaveBeenCalledWith("rum-start"); + expect(initBrowserRum).toHaveBeenCalledWith(rum, store.state.API_ENDPOINT); + }); + it("does not set the RUM account when RUM is disabled", async () => { store.state.zoConfig = { ...originalZoConfig, version: "v1", rum: { enabled: false } }; diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue index 655191250d..8414dd5801 100644 --- a/web/src/layouts/MainLayout.vue +++ b/web/src/layouts/MainLayout.vue @@ -319,6 +319,7 @@ import { type OrgStreamList, } from "@/composables/firstEvent/useFirstDataNotice"; import { openobserveRum } from "@openobserve/browser-rum"; +import { initBrowserRum } from "@/utils/rum/initBrowserRum"; import useSearchWebSocket from "@/composables/useSearchWebSocket"; import O2AIChat from "@/components/O2AIChat.vue"; import PaidOverageConsentDialog from "@/components/PaidOverageConsentDialog.vue"; @@ -844,7 +845,7 @@ export default defineComponent({ }, ); - // main.ts mounts before the bootstrap config resolves, so rum.enabled can arrive after the org. + // rum.enabled arrives only with the full config, which is fetched after the org is known. watch( [ () => store.state.selectedOrganization?.identifier, @@ -1541,6 +1542,8 @@ export default defineComponent({ linksList.value = layoutMixin.leftNavigationLinks(linksList, t); } + // Before setConfig, so watchers that react to rum.enabled find the SDK running. + initBrowserRum(res.data.rum, store.state.API_ENDPOINT); store.dispatch("setConfig", res.data); fullConfigOrg = orgIdentifier; await nextTick(); @@ -1567,11 +1570,6 @@ export default defineComponent({ } // Fail open: reveal the base menu even if the config never resolves. menuReady.value = true; - // Session replay must not be lost to a failed config fetch — the rum - // settings already arrived with the unauthenticated bootstrap. - if (store.state.zoConfig?.rum?.enabled) { - setRumUser(); - } // A transient failure (expired session being refreshed, rolling // deploy) would otherwise strand the session on the bootstrap subset. if (attempt < FULL_CONFIG_RETRY_DELAYS_MS.length) { diff --git a/web/src/locales/languages/ar-SA.json b/web/src/locales/languages/ar-SA.json index 06567ba7f6..1a4f1a4ece 100644 --- a/web/src/locales/languages/ar-SA.json +++ b/web/src/locales/languages/ar-SA.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "لا توجد نتائج مطابقة", "runQuery": "تشغيل الاستعلام", "runOptions": "خيارات التشغيل", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "قيم التوصيف عبر الزمن", "breakdown": "التفصيل", "breakdownNone": "بدون", diff --git a/web/src/locales/languages/de-DE.json b/web/src/locales/languages/de-DE.json index 73bd965a4f..c21e22c290 100644 --- a/web/src/locales/languages/de-DE.json +++ b/web/src/locales/languages/de-DE.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "Keine Treffer", "runQuery": "Abfrage ausführen", "runOptions": "Ausführungsoptionen", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "Profilwerte im Zeitverlauf", "breakdown": "Aufschlüsselung", "breakdownNone": "Keine", diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json index 3cb860234a..b21a410377 100644 --- a/web/src/locales/languages/en-US.json +++ b/web/src/locales/languages/en-US.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "No matches", "runQuery": "Run query", "runOptions": "Run Options", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "Profile values over time", "breakdown": "Breakdown", "breakdownNone": "None", diff --git a/web/src/locales/languages/es-ES.json b/web/src/locales/languages/es-ES.json index dbe1d6c263..90e6d1e013 100644 --- a/web/src/locales/languages/es-ES.json +++ b/web/src/locales/languages/es-ES.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "Sin coincidencias", "runQuery": "Ejecutar consulta", "runOptions": "Opciones de ejecución", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "Valores de perfil a lo largo del tiempo", "breakdown": "Desglose", "breakdownNone": "Ninguno", diff --git a/web/src/locales/languages/fr-FR.json b/web/src/locales/languages/fr-FR.json index 257d05274a..0cecda52f8 100644 --- a/web/src/locales/languages/fr-FR.json +++ b/web/src/locales/languages/fr-FR.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "Aucune correspondance", "runQuery": "Exécuter la requête", "runOptions": "Options d'exécution", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "Valeurs de profil dans le temps", "breakdown": "Répartition", "breakdownNone": "Aucun", diff --git a/web/src/locales/languages/it-IT.json b/web/src/locales/languages/it-IT.json index 42a63739bc..55ef020503 100644 --- a/web/src/locales/languages/it-IT.json +++ b/web/src/locales/languages/it-IT.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "Nessuna corrispondenza", "runQuery": "Esegui query", "runOptions": "Opzioni di esecuzione", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "Valori dei profili nel tempo", "breakdown": "Ripartizione", "breakdownNone": "Nessuno", diff --git a/web/src/locales/languages/ja-JP.json b/web/src/locales/languages/ja-JP.json index 7e74582375..00c9194017 100644 --- a/web/src/locales/languages/ja-JP.json +++ b/web/src/locales/languages/ja-JP.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "一致なし", "runQuery": "クエリを実行", "runOptions": "実行オプション", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "プロファイル値の推移", "breakdown": "内訳", "breakdownNone": "なし", diff --git a/web/src/locales/languages/ko-KR.json b/web/src/locales/languages/ko-KR.json index 29cd6e1ef3..e17d897f31 100644 --- a/web/src/locales/languages/ko-KR.json +++ b/web/src/locales/languages/ko-KR.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "일치 항목 없음", "runQuery": "쿼리 실행", "runOptions": "실행 옵션", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "시간 경과에 따른 프로파일 값", "breakdown": "분류", "breakdownNone": "없음", diff --git a/web/src/locales/languages/nl-NL.json b/web/src/locales/languages/nl-NL.json index 06130ad643..9e8cddbbf8 100644 --- a/web/src/locales/languages/nl-NL.json +++ b/web/src/locales/languages/nl-NL.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "Geen overeenkomsten", "runQuery": "Query uitvoeren", "runOptions": "Uitvoeropties", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "Profielwaarden in de tijd", "breakdown": "Uitsplitsing", "breakdownNone": "Geen", diff --git a/web/src/locales/languages/pl-PL.json b/web/src/locales/languages/pl-PL.json index 15f8c868a2..9da70ce039 100644 --- a/web/src/locales/languages/pl-PL.json +++ b/web/src/locales/languages/pl-PL.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "Brak dopasowań", "runQuery": "Uruchom zapytanie", "runOptions": "Opcje uruchamiania", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "Wartości profilu w czasie", "breakdown": "Podział", "breakdownNone": "Brak", diff --git a/web/src/locales/languages/pt-PT.json b/web/src/locales/languages/pt-PT.json index 72ccd20b58..ec802c9457 100644 --- a/web/src/locales/languages/pt-PT.json +++ b/web/src/locales/languages/pt-PT.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "Sem correspondências", "runQuery": "Executar consulta", "runOptions": "Opções de execução", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "Valores de perfil ao longo do tempo", "breakdown": "Desagregação", "breakdownNone": "Nenhum", diff --git a/web/src/locales/languages/ru-RU.json b/web/src/locales/languages/ru-RU.json index dc7e710068..e7414d1d0d 100644 --- a/web/src/locales/languages/ru-RU.json +++ b/web/src/locales/languages/ru-RU.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "Совпадений нет", "runQuery": "Выполнить запрос", "runOptions": "Параметры запуска", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "Значения профиля во времени", "breakdown": "Разбивка", "breakdownNone": "Нет", diff --git a/web/src/locales/languages/tr-TR.json b/web/src/locales/languages/tr-TR.json index cd85d1f41b..93d0c304d8 100644 --- a/web/src/locales/languages/tr-TR.json +++ b/web/src/locales/languages/tr-TR.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "Eşleşme yok", "runQuery": "Sorguyu çalıştır", "runOptions": "Çalıştırma seçenekleri", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "Zaman içinde profil değerleri", "breakdown": "Kırılım", "breakdownNone": "Hiçbiri", diff --git a/web/src/locales/languages/vi-VN.json b/web/src/locales/languages/vi-VN.json index f0df3f3991..a434d918d3 100644 --- a/web/src/locales/languages/vi-VN.json +++ b/web/src/locales/languages/vi-VN.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "Không có kết quả khớp", "runQuery": "Chạy truy vấn", "runOptions": "Tùy chọn chạy", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "Giá trị profile theo thời gian", "breakdown": "Phân tách", "breakdownNone": "Không có", diff --git a/web/src/locales/languages/zh-CN.json b/web/src/locales/languages/zh-CN.json index 0ab55e9a19..4889eede44 100644 --- a/web/src/locales/languages/zh-CN.json +++ b/web/src/locales/languages/zh-CN.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "无匹配项", "runQuery": "运行查询", "runOptions": "运行选项", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "性能值时间序列", "breakdown": "细分", "breakdownNone": "无", diff --git a/web/src/locales/languages/zh-TW.json b/web/src/locales/languages/zh-TW.json index 7e14919442..fbc7988032 100644 --- a/web/src/locales/languages/zh-TW.json +++ b/web/src/locales/languages/zh-TW.json @@ -1518,6 +1518,7 @@ "searchNoMatches": "無相符項目", "runQuery": "執行查詢", "runOptions": "執行選項", + "runNeedsProfileType": "Pick a stream and a profile type with data in this time range", "timeSeriesTitle": "剖析值隨時間變化", "breakdown": "細分", "breakdownNone": "無", diff --git a/web/src/main.spec.ts b/web/src/main.spec.ts index c07932009e..cc8f29bfee 100644 --- a/web/src/main.spec.ts +++ b/web/src/main.spec.ts @@ -5,8 +5,7 @@ const routerOnError = vi.hoisted(() => vi.fn()); const isStaleChunkError = vi.hoisted(() => vi.fn()); const toast = vi.hoisted(() => vi.fn()); const logsInit = vi.hoisted(() => vi.fn()); -const dispatch = vi.hoisted(() => vi.fn()); -const route = vi.hoisted(() => ({ name: "logs" })); +const setInitialVersion = vi.hoisted(() => vi.fn()); vi.mock("@/services/config.queries", () => ({ configQuery: () => ({}) })); vi.mock("vue", async (importOriginal) => { @@ -16,16 +15,10 @@ vi.mock("vue", async (importOriginal) => { }); vi.mock("@tanstack/vue-query", () => ({ VueQueryPlugin: {} })); vi.mock("./stores", () => ({ - default: { state: { zoConfig: {}, API_ENDPOINT: "http://localhost:5080" }, dispatch }, + default: { state: { zoConfig: {}, API_ENDPOINT: "http://localhost:5080" }, dispatch: vi.fn() }, })); vi.mock("./App.vue", () => ({ default: {} })); -vi.mock("./router", () => ({ - default: () => ({ - onError: routerOnError, - resolve: () => ({ name: route.name }), - options: { history: { location: "/" } }, - }), -})); +vi.mock("./router", () => ({ default: () => ({ onError: routerOnError }) })); vi.mock("./locales", () => ({ default: { global: { t: (key: string) => key } }, applyDocumentLocale: vi.fn(), @@ -41,7 +34,7 @@ vi.mock("./composables/contextProviders", () => ({ createDefaultContextProvider: vi.fn(), })); vi.mock("./utils/buildVersionChecker", () => ({ - buildVersionChecker: { setInitialVersion: vi.fn(), isStaleChunkError }, + buildVersionChecker: { setInitialVersion, isStaleChunkError }, isChunkLoadError: (e: { message?: string } | undefined) => /dynamically imported module|Unable to preload CSS/.test(e?.message ?? ""), })); @@ -49,6 +42,8 @@ vi.mock("./composables/query/queryClient", () => ({ queryClient: { fetchQuery: () => Promise.resolve({ + commit_hash: "abc123", + // A stale server may still send rum in the bootstrap; the SDK must wait for login anyway. rum: { enabled: true, client_token: "t", @@ -66,63 +61,18 @@ vi.mock("./composables/query/queryClient", () => ({ vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast })); vi.mock("@/utils/themeManager", () => ({ bootstrapTheme: vi.fn() })); -const EXT = "chrome-extension://abcdefghijklmnop"; - // main.ts boots the whole app on import, which can outlast the default hook timeout on a busy runner. beforeAll(async () => { await import("./main"); - await vi.waitFor(() => expect(rumInit).toHaveBeenCalled(), { timeout: 20000 }); + await vi.waitFor(() => expect(setInitialVersion).toHaveBeenCalledWith("abc123"), { + timeout: 20000, + }); }, 60000); -const rumBeforeSend = (): ((event: Record) => boolean) => - rumInit.mock.calls[0][0].beforeSend; - -const errorEvent = (stack: string) => ({ - type: "error", - view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" }, - error: { message: "boom", stack }, -}); - -describe("main.ts RUM version", () => { - it("sends the server's build version to both the RUM and logs SDKs", () => { - expect(rumInit.mock.calls[0][0].version).toBe("v0.70.0"); - expect(logsInit.mock.calls[0][0].version).toBe("v0.70.0"); - }); -}); - -describe("main.ts RUM beforeSend", () => { - it("drops an error whose every frame is a Chrome extension", async () => { - const beforeSend = rumBeforeSend(); - const stack = `TypeError: boom\n at inject (${EXT}/content.js:1:1)\n at ${EXT}/x.js:2:2`; - expect(beforeSend(errorEvent(stack))).toBe(false); - }); - - it("sends an error with at least one app frame", async () => { - const beforeSend = rumBeforeSend(); - const stack = `TypeError: boom\n at inject (${EXT}/content.js:1:1)\n at setup (https://cloud.openobserve.ai/web/assets/index.js:3:4)`; - expect(beforeSend(errorEvent(stack))).toBe(true); - }); - - it("still scrubs sign-in tokens from the view URL of an event it sends", async () => { - const beforeSend = rumBeforeSend(); - const event = { - type: "view", - view: { url: "https://cloud.openobserve.ai/web/cb#id_token=abc", referrer: "" }, - }; - expect(beforeSend(event)).toBe(true); - expect(event.view.url).toBe("https://cloud.openobserve.ai/web/cb#id_token=redacted"); - }); - - it("drops a JWT from a click's target name", async () => { - const beforeSend = rumBeforeSend(); - const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2ln"; - const event = { - type: "action", - view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" }, - action: { target: { name: `Copy ${jwt}` } }, - }; - expect(beforeSend(event)).toBe(true); - expect(event.action.target.name).toBe("Copy "); +describe("main.ts RUM", () => { + it("does not start the RUM or logs SDK from the unauthenticated bootstrap config", () => { + expect(rumInit).not.toHaveBeenCalled(); + expect(logsInit).not.toHaveBeenCalled(); }); }); @@ -226,18 +176,3 @@ describe("main.ts chunk load errors", () => { 60000, ); }); - -describe("main.ts RUM on public dashboards", () => { - it("starts neither the RUM nor the logs SDK for an anonymous public-link viewer", async () => { - route.name = "publicDashboard"; - rumInit.mockClear(); - logsInit.mockClear(); - dispatch.mockClear(); - vi.resetModules(); - await import("./main"); - await vi.waitFor(() => expect(dispatch).toHaveBeenCalledWith("setConfig", expect.anything())); - expect(rumInit).not.toHaveBeenCalled(); - expect(logsInit).not.toHaveBeenCalled(); - route.name = "logs"; - }, 60000); -}); diff --git a/web/src/main.ts b/web/src/main.ts index 97a32cb58c..a8325fa490 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -28,16 +28,10 @@ import "./styles/rtl.css"; import "./assets/styles/log-highlighting.css"; import config from "./aws-exports"; -import { openobserveRum } from "@openobserve/browser-rum"; -import { openobserveLogs } from "@openobserve/browser-logs"; import { useReo } from "./services/reodotdev_analytics"; import { contextRegistry, createDefaultContextProvider } from "./composables/contextProviders"; import { buildVersionChecker, isChunkLoadError } from "./utils/buildVersionChecker"; import { queryClient, setMutationNotifier } from "./composables/query/queryClient"; -import { shouldPropagateTracing } from "./utils/rum/tracingOrigin"; -import { isIgnoredNoise } from "./utils/rum/ignoredNoisePatterns"; -import { scrubAuthParams } from "./utils/rum/scrubAuthParams"; -import { isExtensionOnlyError } from "./utils/rum/extensionFrames"; import { toast } from "@/lib/feedback/Toast/useToast"; import { bootstrapTheme } from "@/utils/themeManager"; import { raw } from "@/types/i18n"; @@ -80,28 +74,9 @@ interface ConfigResponse { telemetry_enabled?: boolean; build_type?: string; commit_hash?: string; - rum?: { - enabled: boolean; - client_token: string; - application_id: string; - site: string; - service: string; - env: string; - version?: string; - organization_identifier: string; - insecure_http?: boolean; - api_version?: string; - }; }; } -// Only the three dot-separated parts make a token, so a bare eyJ inside a word (surveyJsonBtn) is kept. -const JWT_PATTERN = /eyJ[\w-]*\.[\w-]+\.[\w-]*/g; - -// Anonymous public-link viewers are not app users, so their sessions stay out of RUM. -const isPublicDashboard = - router.resolve(router.options.history.location).name === "publicDashboard"; - const getConfig = async () => { // Seeds the shared bootstrap `/config` query — Login and the version checker // read the same cached entry instead of each issuing their own request. The @@ -122,129 +97,6 @@ const getConfig = async () => { if (res.data.commit_hash) { buildVersionChecker.setInitialVersion(res.data.commit_hash); } - if (res.data.rum?.enabled && !isPublicDashboard) { - const options = { - clientToken: res.data.rum.client_token, - applicationId: res.data.rum.application_id, - site: res.data.rum.site, - service: res.data.rum.service, - env: res.data.rum.env, - version: res.data.rum.version || "0.0.1", - organizationIdentifier: res.data.rum.organization_identifier, - insecureHTTP: res.data.rum.insecure_http || false, - apiVersion: res.data.rum.api_version || "v1", - }; - - openobserveRum.init({ - applicationId: options.applicationId, // required, any string identifying your application - clientToken: options.clientToken, - site: options.site, - organizationIdentifier: options.organizationIdentifier, - service: options.service, - env: options.env, - version: options.version, - trackResources: true, - trackLongTasks: true, - trackUserInteractions: true, - actionNameAttribute: "data-test", - apiVersion: options.apiVersion, - insecureHTTP: options.insecureHTTP, - defaultPrivacyLevel: "allow", - // Session renewal drops the forced replay start, so sampling must keep every renewed session recording. - sessionReplaySampleRate: 100, - startSessionReplayRecordingManually: true, - // Same-origin only: cross-origin (dev against a remote cluster) the - // injected headers fail the CORS preflight and kill every API call. - // See shouldPropagateTracing. - allowedTracingUrls: shouldPropagateTracing(store.state.API_ENDPOINT, window.location.origin) - ? [ - { - match: store.state.API_ENDPOINT + "/api", - propagatorTypes: ["openobserve", "tracecontext"], - }, - ] - : [], - beforeSend: (event) => { - event.view.url = scrubAuthParams(event.view.url) ?? event.view.url; - event.view.referrer = scrubAuthParams(event.view.referrer); - if (event.type === "resource") { - event.resource.url = scrubAuthParams(event.resource.url) ?? event.resource.url; - } - if (event.type === "error") { - event.error.message = scrubAuthParams(event.error.message) ?? event.error.message; - if (event.error.resource) { - event.error.resource.url = - scrubAuthParams(event.error.resource.url) ?? event.error.resource.url; - } - } - if (event.type === "error" && isExtensionOnlyError(event.error?.stack)) return false; - if (event.type === "action" && event.action.target?.name) { - event.action.target.name = event.action.target.name.replace(JWT_PATTERN, ""); - } - // Filter out specific errors before sending to RUM - if (event.type === "error") { - const errorMessage = event.error?.message || ""; - const errorStack = event.error?.stack || ""; - - // List of error patterns to ignore - const ignoredErrorPatterns = [ - /ResizeObserver loop/i, - /reo.dev/i, - /Cannot set properties of null \(setting 'innerHTML'\)/, - ]; - - // Check if error matches any ignored pattern - const shouldIgnore = ignoredErrorPatterns.some( - (pattern) => pattern.test(errorMessage) || pattern.test(errorStack), - ); - - if (shouldIgnore || isIgnoredNoise(errorMessage, errorStack)) { - return false; // Don't send this error - } - } - - // Allow all other events to be sent - return true; - }, - }); - - openobserveLogs.init({ - clientToken: options.clientToken, - site: options.site, - organizationIdentifier: options.organizationIdentifier, - service: options.service, - env: options.env, - version: options.version, - forwardErrorsToLogs: true, - insecureHTTP: options.insecureHTTP, - apiVersion: options.apiVersion, - beforeSend: (log) => { - if (log.view?.url) log.view.url = scrubAuthParams(log.view.url) ?? log.view.url; - if (log.view?.referrer) log.view.referrer = scrubAuthParams(log.view.referrer); - if (log.http?.url) log.http.url = scrubAuthParams(log.http.url) ?? log.http.url; - log.message = scrubAuthParams(log.message) ?? log.message; - // Filter out specific logs before sending - const logMessage = log.message || ""; - - // List of log patterns to ignore - const ignoredLogPatterns = [/ResizeObserver loop/i]; - - // Check if log matches any ignored pattern - const shouldIgnore = ignoredLogPatterns.some((pattern) => pattern.test(logMessage)); - - if (shouldIgnore || isIgnoredNoise(logMessage)) { - return false; // Don't send this log - } - - // Allow all other logs to be sent - return true; - }, - }); - - // Don't start session replay automatically - it will be started after login - // to avoid capturing login page with "Unknown" user - // Session replay will be started in Login.vue and MainLayout.vue after user is authenticated - } }); }; diff --git a/web/src/plugins/logs/FunctionSelector.vue b/web/src/plugins/logs/FunctionSelector.vue index afd0ecb456..5295f0d8cb 100644 --- a/web/src/plugins/logs/FunctionSelector.vue +++ b/web/src/plugins/logs/FunctionSelector.vue @@ -14,9 +14,7 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . -->