mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 00:09:35 +00:00
Merge remote-tracking branch 'origin/main' into feat/onboarding-survey-trim
This commit is contained in:
@@ -20128,6 +20128,7 @@
|
||||
"resetFocus": "52e4a8f135999837",
|
||||
"resetZoom": "3a43acdce0ce0a6c",
|
||||
"rootAtBottom": "c56857a7058a5f54",
|
||||
"runNeedsProfileType": "cce3ebf0fef22891",
|
||||
"runOptions": "351dabc4ae8669a2",
|
||||
"runQuery": "1837e00c45e8663d",
|
||||
"searchForFunction": "47fc3e0c4a4f8aa6",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<i64>().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::<i64>().unwrap_or(0)),
|
||||
size: query
|
||||
.get("size")
|
||||
.map_or(10, |v| v.parse::<i64>().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 [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
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<String, i64>,
|
||||
rows: HashMap<String, json::Value>,
|
||||
}
|
||||
|
||||
impl CandidateMerge {
|
||||
fn new(cap: usize) -> Self {
|
||||
Self {
|
||||
cap,
|
||||
candidates: HashMap::new(),
|
||||
rows: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_hits(&mut self, hits: Vec<json::Value>) {
|
||||
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<json::Value> {
|
||||
let mut rows: Vec<json::Value> = 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::<Result<StreamResponses, infra::errors::Error>>(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<TopKHeap> = if is_non_ts_order_by {
|
||||
let mut candidate_merge = ranks_by_candidate.then(|| CandidateMerge::new(heap_k));
|
||||
let mut topk_heap: Option<TopKHeap> = 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<json::Value>, limit: usize, ids_ascending: bool) -> Vec<json::Value> {
|
||||
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<json::Value>],
|
||||
from: i64,
|
||||
size: i64,
|
||||
ids_ascending: bool,
|
||||
) -> Vec<String> {
|
||||
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![
|
||||
|
||||
@@ -186,7 +186,7 @@ pub async fn traces_write(
|
||||
("stream_name" = String, Path, description = "Stream name"),
|
||||
("filter" = Option<String>, 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<i64>, 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<String>, 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<i64>, Query, description = "timeout, seconds"),
|
||||
("sort_by" = Option<String>, Query, description = "sort by field: start_time, duration (default: start_time)"),
|
||||
("sort_by" = Option<String>, 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<String>, Query, description = "sort order: asc, desc (default: desc)"),
|
||||
),
|
||||
responses(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+52
-4
@@ -65,8 +65,7 @@ pub async fn get_db_schema_version() -> Result<u64, anyhow::Error> {
|
||||
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<std::borrow::Cow<'_, str>> {
|
||||
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<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]'
|
||||
);
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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().
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -364,7 +364,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
data-test="date-time-apply-btn"
|
||||
variant="primary"
|
||||
size="xs"
|
||||
class="element-box-shadow"
|
||||
@click="applyAndClose"
|
||||
>
|
||||
{{ t("common.apply") }}
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1518,6 +1518,7 @@
|
||||
"searchNoMatches": "لا توجد نتائج مطابقة",
|
||||
"runQuery": "تشغيل الاستعلام",
|
||||
"runOptions": "خيارات التشغيل",
|
||||
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
|
||||
"timeSeriesTitle": "قيم التوصيف عبر الزمن",
|
||||
"breakdown": "التفصيل",
|
||||
"breakdownNone": "بدون",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1518,6 +1518,7 @@
|
||||
"searchNoMatches": "一致なし",
|
||||
"runQuery": "クエリを実行",
|
||||
"runOptions": "実行オプション",
|
||||
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
|
||||
"timeSeriesTitle": "プロファイル値の推移",
|
||||
"breakdown": "内訳",
|
||||
"breakdownNone": "なし",
|
||||
|
||||
@@ -1518,6 +1518,7 @@
|
||||
"searchNoMatches": "일치 항목 없음",
|
||||
"runQuery": "쿼리 실행",
|
||||
"runOptions": "실행 옵션",
|
||||
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
|
||||
"timeSeriesTitle": "시간 경과에 따른 프로파일 값",
|
||||
"breakdown": "분류",
|
||||
"breakdownNone": "없음",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1518,6 +1518,7 @@
|
||||
"searchNoMatches": "Совпадений нет",
|
||||
"runQuery": "Выполнить запрос",
|
||||
"runOptions": "Параметры запуска",
|
||||
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
|
||||
"timeSeriesTitle": "Значения профиля во времени",
|
||||
"breakdown": "Разбивка",
|
||||
"breakdownNone": "Нет",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ó",
|
||||
|
||||
@@ -1518,6 +1518,7 @@
|
||||
"searchNoMatches": "无匹配项",
|
||||
"runQuery": "运行查询",
|
||||
"runOptions": "运行选项",
|
||||
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
|
||||
"timeSeriesTitle": "性能值时间序列",
|
||||
"breakdown": "细分",
|
||||
"breakdownNone": "无",
|
||||
|
||||
@@ -1518,6 +1518,7 @@
|
||||
"searchNoMatches": "無相符項目",
|
||||
"runQuery": "執行查詢",
|
||||
"runOptions": "執行選項",
|
||||
"runNeedsProfileType": "Pick a stream and a profile type with data in this time range",
|
||||
"timeSeriesTitle": "剖析值隨時間變化",
|
||||
"breakdown": "細分",
|
||||
"breakdownNone": "無",
|
||||
|
||||
+13
-78
@@ -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<string, unknown>) => 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);
|
||||
});
|
||||
|
||||
-148
@@ -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
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<template>
|
||||
<OButtonGroup
|
||||
class="function-selector element-box-shadow border-button-outline-border float-left me-1 border p-0"
|
||||
>
|
||||
<OButtonGroup class="function-selector border-button-outline-border float-left me-1 border p-0">
|
||||
<div v-if="!hideToggle" class="flex items-center px-1">
|
||||
<OSwitch
|
||||
data-test="logs-search-bar-show-query-toggle-btn"
|
||||
@@ -30,7 +28,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OButton
|
||||
data-test="logs-search-bar-function-dropdown"
|
||||
variant="ghost"
|
||||
class="ms-1!"
|
||||
class="ms-1! size-8!"
|
||||
size="icon-toolbar"
|
||||
>
|
||||
<img :src="functionIconUrl" :alt="t('logs.functionSelector.function')" class="size-4" />
|
||||
@@ -74,6 +72,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
data-test="logs-search-bar-save-function-btn"
|
||||
variant="ghost"
|
||||
size="icon-toolbar"
|
||||
class="size-8!"
|
||||
@click="fnSavedFunctionDialog"
|
||||
>
|
||||
<OIcon name="save" size="sm" />
|
||||
|
||||
@@ -254,4 +254,25 @@ describe("SearchBar — pinned saved views", () => {
|
||||
|
||||
expect(group().exists()).toBe(false);
|
||||
});
|
||||
|
||||
// 89px = 1px border + 54px list trigger + 1px separator + 32px create button + 1px border.
|
||||
it.each([
|
||||
[698, false],
|
||||
[699, true],
|
||||
])(
|
||||
"budgets the group at its rendered 89px width (toolbar %ipx → shown: %s)",
|
||||
async (width, shown) => {
|
||||
const otherPins = (["histogram", "sqlMode", "quickMode", "syntaxGuide"] as const).filter(
|
||||
(key) => isPinned(key),
|
||||
);
|
||||
otherPins.forEach((key) => togglePin(key));
|
||||
try {
|
||||
wrapper = mountSearchBar(width);
|
||||
await flushPromises();
|
||||
expect(group().exists()).toBe(shown);
|
||||
} finally {
|
||||
otherPins.forEach((key) => togglePin(key));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -102,4 +102,48 @@ describe("SearchBar — toolbar controls match the view-mode toggle height", ()
|
||||
expectToolbarHeight("logs-view-mode-dropdown-btn");
|
||||
expectToolbarHeight("logs-search-bar-utilities-menu-btn");
|
||||
});
|
||||
|
||||
it("raises every right-hand control to the same 34px row height", async () => {
|
||||
const innerWidth = window.innerWidth;
|
||||
Object.defineProperty(window, "innerWidth", { value: 1600, configurable: true });
|
||||
if (!isPinned("functionEditor")) {
|
||||
togglePin("functionEditor");
|
||||
pinnedByTest.push("functionEditor");
|
||||
}
|
||||
try {
|
||||
wrapper = mountSearchBar(1600);
|
||||
await flushPromises();
|
||||
wrapper.vm.searchObj.meta.showTransformEditor = true;
|
||||
await flushPromises();
|
||||
|
||||
const classesOf = (dataTest: string) => wrapper!.get(`[data-test="${dataTest}"]`).classes();
|
||||
for (const dataTest of [
|
||||
"logs-search-bar-more-options-btn",
|
||||
"logs-search-bar-share-link-btn",
|
||||
]) {
|
||||
expect(classesOf(dataTest)).toContain("size-[2.125rem]!");
|
||||
}
|
||||
for (const dataTest of [
|
||||
"logs-search-bar-function-editor-pinned-btn",
|
||||
"logs-search-bar-date-time-dropdown",
|
||||
"logs-search-bar-refresh-btn",
|
||||
"logs-search-bar-refresh-cache-dropdown-trigger",
|
||||
]) {
|
||||
expect(classesOf(dataTest)).toContain(TOGGLE_GROUP_HEIGHT_CLASS);
|
||||
}
|
||||
const runButton = wrapper.get('[data-test="logs-search-bar-refresh-btn"]').element;
|
||||
expect(runButton.nextElementSibling!.classList).toContain(TOGGLE_GROUP_HEIGHT_CLASS);
|
||||
const interval = wrapper.get('[data-test="logs-search-bar-refresh-interval-btn"]').element;
|
||||
expect(interval.parentElement!.classList).toContain("*:size-[2.125rem]!");
|
||||
// A 1px group border around 2rem buttons makes the function selector 34px.
|
||||
for (const dataTest of [
|
||||
"logs-search-bar-function-dropdown",
|
||||
"logs-search-bar-save-function-btn",
|
||||
]) {
|
||||
expect(classesOf(dataTest)).toContain("size-8!");
|
||||
}
|
||||
} finally {
|
||||
Object.defineProperty(window, "innerWidth", { value: innerWidth, configurable: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
|
||||
import i18n from "@/locales";
|
||||
import store from "@/test/unit/helpers/store";
|
||||
import { createRouter, createMemoryHistory } from "vue-router";
|
||||
import SearchBar from "@/plugins/logs/SearchBar.vue";
|
||||
import { resetLogsAutoRunForTests } from "@/composables/useLogs/logsAutoRun";
|
||||
import { useToolbarPins, type ToolbarPinKey } from "@/composables/useToolbarPins";
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: "/logs", name: "logs", component: { template: "<div />" } }],
|
||||
});
|
||||
|
||||
const UNPREFIXED_HORIZONTAL_MARGIN = /^-?m[xse]?-|^-?m[lr]-/;
|
||||
|
||||
describe("SearchBar — toolbar controls are spaced only by the row gap", () => {
|
||||
let wrapper: VueWrapper<any> | undefined;
|
||||
const { isPinned, togglePin } = useToolbarPins();
|
||||
const pinnedByTest: ToolbarPinKey[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
resetLogsAutoRunForTests(store);
|
||||
for (const key of [
|
||||
"histogram",
|
||||
"sqlMode",
|
||||
"quickMode",
|
||||
"savedViews",
|
||||
"syntaxGuide",
|
||||
] as ToolbarPinKey[]) {
|
||||
if (!isPinned(key)) {
|
||||
togglePin(key);
|
||||
pinnedByTest.push(key);
|
||||
}
|
||||
}
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
) {
|
||||
return { width: this.classList.contains("border-b-card-glass-border") ? 1600 : 0 } as DOMRect;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = undefined;
|
||||
while (pinnedByTest.length) togglePin(pinnedByTest.pop()!);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const marginClassesOf = (el: Element) =>
|
||||
[...el.classList].filter((c) => UNPREFIXED_HORIZONTAL_MARGIN.test(c));
|
||||
|
||||
it("adds no horizontal margin on top of the gap-1 between adjacent controls", async () => {
|
||||
wrapper = mount(SearchBar, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
provide: { store },
|
||||
plugins: [i18n, router],
|
||||
stubs: { QueryEditor: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const left = wrapper.vm.$refs.toolbarLeftRef as HTMLElement;
|
||||
const collapsible = left.firstElementChild as HTMLElement;
|
||||
expect(left.classList).toContain("gap-1");
|
||||
expect(collapsible.classList).toContain("gap-1");
|
||||
|
||||
const more = wrapper.get('[data-test="logs-search-bar-utilities-menu-btn"]');
|
||||
expect(marginClassesOf(more.element)).toEqual([]);
|
||||
expect(more.classes()).toContain("max-md:ms-auto");
|
||||
|
||||
const controls = [...collapsible.children, ...[...left.children].slice(1)];
|
||||
for (const dataTest of [
|
||||
"logs-logs-toggle",
|
||||
"logs-search-bar-reset-filters-btn",
|
||||
"logs-search-bar-histogram-btn",
|
||||
"logs-search-bar-sql-mode-pinned-btn",
|
||||
"logs-search-bar-quick-mode-pinned-btn",
|
||||
"logs-search-bar-saved-views-pinned",
|
||||
]) {
|
||||
expect(
|
||||
controls.some(
|
||||
(c) =>
|
||||
c.querySelector(`[data-test="${dataTest}"]`) || c.matches(`[data-test="${dataTest}"]`),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(controls.length).toBeGreaterThanOrEqual(8);
|
||||
for (const control of controls) {
|
||||
expect(marginClassesOf(control), control.outerHTML.slice(0, 120)).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -243,7 +243,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OButtonGroup
|
||||
v-if="showPinnedSavedViews"
|
||||
data-test="logs-search-bar-saved-views-pinned"
|
||||
class="element-box-shadow border-button-outline-border border p-0"
|
||||
class="border-button-outline-border border p-0"
|
||||
>
|
||||
<!-- A real dropdown, not a modal: one click to open, one click to
|
||||
apply. The heavyweight list dialog stays reachable via Manage. -->
|
||||
@@ -365,7 +365,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<template #trigger>
|
||||
<OButton
|
||||
data-test="logs-search-bar-utilities-menu-btn"
|
||||
class="rounded-default hover:bg-button-outline-hover-bg element-box-shadow ms-1 h-[2.125rem]! p-1! text-xs font-medium [border:0.0625rem_solid_var(--color-button-outline-border)]! [transition:all_0.2s_ease] max-md:ms-auto"
|
||||
class="rounded-default hover:bg-button-outline-hover-bg h-[2.125rem]! p-1! text-xs font-medium [border:0.0625rem_solid_var(--color-button-outline-border)]! [transition:all_0.2s_ease] max-md:ms-auto"
|
||||
icon-left="more-horiz"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
@@ -650,7 +650,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<template #trigger>
|
||||
<OButton
|
||||
data-test="logs-search-bar-more-options-btn"
|
||||
class="download-logs-btn hover:bg-interactive-hover-bg! order-4"
|
||||
class="download-logs-btn hover:bg-interactive-hover-bg! order-4 size-[2.125rem]!"
|
||||
variant="outline"
|
||||
size="icon-toolbar"
|
||||
>
|
||||
@@ -912,7 +912,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
size="icon-toolbar"
|
||||
:disabled="!!shareReason"
|
||||
:tooltip="shareReason || undefined"
|
||||
class="order-3"
|
||||
class="order-3 size-[2.125rem]!"
|
||||
/>
|
||||
<!-- Function Editor (pinned) — sits to the left of the date picker -->
|
||||
<OButton
|
||||
@@ -921,7 +921,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
data-test="logs-search-bar-function-editor-pinned-btn"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
class="element-box-shadow order-1 me-1 gap-1.5"
|
||||
class="order-1 me-1 h-[2.125rem]! gap-1.5"
|
||||
@click="searchObj.meta.showTransformEditor = !searchObj.meta.showTransformEditor"
|
||||
>
|
||||
<OSwitch
|
||||
@@ -952,14 +952,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
@on:date-change="updateDateTime"
|
||||
@on:timezone-change="updateTimezone"
|
||||
:disable="disable"
|
||||
class="element-box-shadow"
|
||||
class="h-[2.125rem]!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="search-time order-2">
|
||||
<div class="flex">
|
||||
<OButtonGroup
|
||||
class="element-box-shadow border-card-glass-border me-1 border p-0"
|
||||
class="border-card-glass-border me-1 border p-0"
|
||||
v-if="
|
||||
config.isEnterprise == 'true' &&
|
||||
Object.keys(store.state.regionInfo).length > 0 &&
|
||||
@@ -1010,7 +1010,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:title="t('search.cancel')"
|
||||
variant="ghost"
|
||||
size="sm-toolbar"
|
||||
class="bg-cancel-query-bg! text-button-primary-foreground! element-box-shadow rounded-s-default! h-[1.875rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
|
||||
class="bg-cancel-query-bg! text-button-primary-foreground! rounded-s-default! h-[2.125rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
|
||||
@click="cancelVisualizeQueries"
|
||||
>{{ t("search.cancel") }}</OButton
|
||||
>
|
||||
@@ -1021,17 +1021,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:title="t('search.runQuery')"
|
||||
:disabled="buildRunBlocked"
|
||||
size="sm-toolbar"
|
||||
class="element-box-shadow bg-button-primary! text-button-primary-foreground! hover:shadow-button-primary/70 rounded-s-default! h-[1.875rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease] hover:opacity-90 hover:shadow-md"
|
||||
class="bg-button-primary! text-button-primary-foreground! hover:shadow-button-primary/70 rounded-s-default! h-[2.125rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease] hover:opacity-90 hover:shadow-md"
|
||||
@click="handleRunQueryFn()"
|
||||
>
|
||||
{{ t("search.runQuery") }}
|
||||
</OButton>
|
||||
<OSeparator class="h-[1.875rem]! w-px" vertical />
|
||||
<OSeparator class="h-[2.125rem]! w-px" vertical />
|
||||
<ODropdown align="end" side="bottom">
|
||||
<template #trigger>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
class="h-[2.125rem]!"
|
||||
:aria-label="t('search.refreshCacheAndRunQuery')"
|
||||
data-test="logs-search-bar-refresh-cache-dropdown-trigger"
|
||||
:class="[
|
||||
@@ -1066,7 +1067,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
variant="ghost"
|
||||
:title="t('search.cancel')"
|
||||
size="sm-toolbar"
|
||||
class="bg-cancel-query-bg! text-button-primary-foreground! element-box-shadow rounded-s-default! h-[1.875rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
|
||||
class="bg-cancel-query-bg! text-button-primary-foreground! rounded-s-default! h-[2.125rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
|
||||
@click="cancelVisualizeQueries"
|
||||
>{{ t("search.cancel") }}</OButton
|
||||
>
|
||||
@@ -1078,17 +1079,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:title="t('search.runQuery')"
|
||||
:disabled="buildRunBlocked || disable"
|
||||
size="sm-toolbar"
|
||||
class="element-box-shadow bg-button-primary! text-button-primary-foreground! hover:shadow-button-primary/70 rounded-s-default! h-[1.875rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease] hover:opacity-90 hover:shadow-md"
|
||||
class="bg-button-primary! text-button-primary-foreground! hover:shadow-button-primary/70 rounded-s-default! h-[2.125rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease] hover:opacity-90 hover:shadow-md"
|
||||
@click="handleRunQueryFn()"
|
||||
>
|
||||
{{ t("search.runQuery") }}
|
||||
</OButton>
|
||||
<OSeparator class="h-[1.875rem]! w-px" vertical />
|
||||
<OSeparator class="h-[2.125rem]! w-px" vertical />
|
||||
<ODropdown align="end" side="bottom">
|
||||
<template #trigger>
|
||||
<OButton
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
class="h-[2.125rem]!"
|
||||
:aria-label="t('search.refreshCacheAndRunQuery')"
|
||||
data-test="logs-search-bar-refresh-cache-dropdown-trigger"
|
||||
:class="[
|
||||
@@ -1124,7 +1126,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
variant="ghost"
|
||||
:title="t('search.cancel')"
|
||||
size="sm-toolbar"
|
||||
class="bg-cancel-query-bg! text-button-primary-foreground! element-box-shadow rounded-default h-[1.875rem]! w-[5.875rem]! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
|
||||
class="bg-cancel-query-bg! text-button-primary-foreground! rounded-default h-[2.125rem]! w-[5.875rem]! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
|
||||
@click="autoRun.engine.cancelGeneration(null, { cause: 'user' })"
|
||||
>{{ t("search.cancel") }}</OButton
|
||||
>
|
||||
@@ -1135,7 +1137,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
variant="primary"
|
||||
:title="t('search.cancel')"
|
||||
size="sm-toolbar"
|
||||
class="bg-cancel-query-bg! text-button-primary-foreground! element-box-shadow rounded-s-default! h-[1.875rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
|
||||
class="bg-cancel-query-bg! text-button-primary-foreground! rounded-s-default! h-[2.125rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
|
||||
@click="cancelRun"
|
||||
>{{ t("search.cancel") }}</OButton
|
||||
>
|
||||
@@ -1148,7 +1150,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
:variant="showRunQueryPending ? 'pending' : 'primary'"
|
||||
:title="t('search.runQuery')"
|
||||
size="sm-toolbar"
|
||||
class="element-box-shadow rounded-s-default! relative h-[1.875rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
|
||||
class="rounded-s-default! relative h-[2.125rem]! w-[5.875rem]! rounded-e-none! p-0 px-1! text-center leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,opacity_0.2s_ease]"
|
||||
:class="[
|
||||
showRunQueryPending
|
||||
? ''
|
||||
@@ -1182,7 +1184,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</OButton>
|
||||
<OSeparator
|
||||
v-if="!showRunQueryPending || isRunQueryCancelShown"
|
||||
class="h-[1.875rem]! w-px"
|
||||
class="h-[2.125rem]! w-px"
|
||||
vertical
|
||||
/>
|
||||
<ODropdown align="end" side="bottom">
|
||||
@@ -1190,6 +1192,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OButton
|
||||
:variant="showRunQueryPending && !isRunQueryCancelShown ? 'pending' : 'ghost'"
|
||||
size="icon-xs"
|
||||
class="h-[2.125rem]!"
|
||||
:aria-label="t('search.refreshCacheAndRunQuery')"
|
||||
data-test="logs-search-bar-refresh-cache-dropdown-trigger"
|
||||
:class="[
|
||||
@@ -1247,7 +1250,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</ODropdown>
|
||||
<!-- Compact Auto Refresh Button -->
|
||||
<AutoRefreshInterval
|
||||
class="ms-1"
|
||||
class="ms-1 *:size-[2.125rem]!"
|
||||
v-model="searchObj.meta.refreshInterval"
|
||||
:trigger="true"
|
||||
:is-compact="true"
|
||||
@@ -2445,7 +2448,7 @@ export default defineComponent({
|
||||
// Approximate rendered widths (px) of each pinned control and of the fixed
|
||||
// left-section content, used only to decide how many pinned items fit before
|
||||
// they would clip. Hidden pinned items stay reachable inside the More menu.
|
||||
const PIN_ITEM_WIDTH = { histogram: 46, sqlMode: 46, quickMode: 46, savedViews: 87 };
|
||||
const PIN_ITEM_WIDTH = { histogram: 46, sqlMode: 46, quickMode: 46, savedViews: 89 };
|
||||
const SYNTAX_GUIDE_LABEL_WIDTH = 108;
|
||||
const SYNTAX_GUIDE_ICON_WIDTH = 40;
|
||||
const PIN_ITEM_GAP = 4;
|
||||
@@ -5393,7 +5396,6 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.logs-search-bar-component .download-logs-btn {
|
||||
height: 1.875rem;
|
||||
border-radius: var(--radius-default);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
@@ -5415,7 +5417,7 @@ export default defineComponent({
|
||||
font-weight: 600;
|
||||
font-size: var(--text-xs);
|
||||
padding-left: 0.5rem;
|
||||
height: 1.875rem;
|
||||
height: 2rem;
|
||||
padding-top: 0.1875rem;
|
||||
border-radius: var(--radius-default);
|
||||
}
|
||||
|
||||
@@ -251,7 +251,9 @@ describe("Profiles page", () => {
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.meta).toHaveBeenCalledTimes(1);
|
||||
// Once to learn the services, once more for the picked service's profile types.
|
||||
expect(mocks.meta).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.meta.mock.calls[1][2]).toMatchObject({ service_name: "service-a" });
|
||||
expect(mocks.merge).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.series).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -265,7 +267,7 @@ describe("Profiles page", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findComponent({ name: "DateTime" }).exists()).toBe(true);
|
||||
expect(mocks.meta).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.meta).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.merge).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.series).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -945,7 +947,8 @@ describe("Profiles page", () => {
|
||||
valueType: "relative",
|
||||
});
|
||||
await flushPromises();
|
||||
expect(mocks.meta).toHaveBeenCalledTimes(2);
|
||||
// Two on open, one for the new service's profile types, one for the new window.
|
||||
expect(mocks.meta).toHaveBeenCalledTimes(4);
|
||||
expect(mocks.merge).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
@@ -967,6 +970,110 @@ describe("Profiles page", () => {
|
||||
expect(wrapper.find('[data-test="profiles-no-results"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("lists only the profile types of the selected service", async () => {
|
||||
const typesByService: Record<string, Array<{ type: string; unit: string }>> = {
|
||||
"service-a": [
|
||||
{ type: "cpu", unit: "nanoseconds" },
|
||||
{ type: "heap", unit: "bytes" },
|
||||
],
|
||||
checkout: [{ type: "cpu", unit: "nanoseconds" }],
|
||||
};
|
||||
mocks.meta.mockImplementation(async (_org, _stream, payload) => ({
|
||||
data: {
|
||||
...profileMetaResponse.data,
|
||||
services: ["service-a", "checkout"],
|
||||
profile_types: payload.service_name
|
||||
? typesByService[payload.service_name]
|
||||
: Object.values(typesByService).flat(),
|
||||
},
|
||||
}));
|
||||
const wrapper = mountPage();
|
||||
await settle();
|
||||
const typeOptions = () =>
|
||||
wrapper
|
||||
.findAll('[data-test="profiles-type-select"] option')
|
||||
.map((option) => option.attributes("value"));
|
||||
expect(typeOptions()).toEqual(["cpu\u0000nanoseconds", "heap\u0000bytes"]);
|
||||
|
||||
await wrapper.find('[data-test="profiles-service-select"] select').setValue("checkout");
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.meta.mock.lastCall?.[2]).toMatchObject({ service_name: "checkout" });
|
||||
expect(typeOptions()).toEqual(["cpu\u0000nanoseconds"]);
|
||||
expect(mocks.merge.mock.lastCall?.[2]).toMatchObject({
|
||||
service_name: "checkout",
|
||||
profile_type: "cpu",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the chosen service through an empty window and says why Run query is off", async () => {
|
||||
let empty = false;
|
||||
mocks.meta.mockImplementation(async () => ({
|
||||
data: empty
|
||||
? { ...profileMetaResponse.data, services: [], profile_types: [], label_names: [] }
|
||||
: { ...profileMetaResponse.data, services: ["service-a", "payments"] },
|
||||
}));
|
||||
const wrapper = mountPage();
|
||||
await settle();
|
||||
await wrapper.find('[data-test="profiles-service-select"] select').setValue("payments");
|
||||
await flushPromises();
|
||||
const picker = wrapper.findComponent({ name: "DateTimePickerDashboard" });
|
||||
const pickWindow = async (relativeTimePeriod: string) => {
|
||||
picker.vm.$emit("update:modelValue", {
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
relativeTimePeriod,
|
||||
valueType: "relative",
|
||||
});
|
||||
await flushPromises();
|
||||
};
|
||||
|
||||
empty = true;
|
||||
await pickWindow("5m");
|
||||
const run = wrapper.find('[data-test="profiles-run-query"]');
|
||||
expect(run.attributes("disabled")).toBeDefined();
|
||||
expect(run.find("span[content]").attributes("content")).toBe(
|
||||
"Pick a stream and a profile type with data in this time range",
|
||||
);
|
||||
|
||||
empty = false;
|
||||
await pickWindow("1h");
|
||||
expect(mocks.meta.mock.lastCall?.[2]).toMatchObject({ service_name: "payments" });
|
||||
expect(
|
||||
(wrapper.find('[data-test="profiles-service-select"] select').element as HTMLSelectElement)
|
||||
.value,
|
||||
).toBe("payments");
|
||||
expect(mocks.merge.mock.lastCall?.[2]).toMatchObject({ service_name: "payments" });
|
||||
});
|
||||
|
||||
it("draws a lone point as a dot on an axis that spans the run's window", async () => {
|
||||
const startMs = mocks.nowMs - 15 * 60 * 1000;
|
||||
mocks.series.mockResolvedValue({
|
||||
data: { ...seriesResponse.data, series: [{ timestamp: startMs * 1000, value: 6e9 }] },
|
||||
});
|
||||
const wrapper = mountPage();
|
||||
await settle();
|
||||
|
||||
const options = () =>
|
||||
wrapper.findComponent({ name: "ChartRendererStub" }).props("data").options;
|
||||
expect(options().series[0].showSymbol).toBe(true);
|
||||
expect(options().xAxis.min).toBe(startMs);
|
||||
expect(options().xAxis.max).toBe(mocks.nowMs);
|
||||
|
||||
mocks.series.mockResolvedValue({
|
||||
data: {
|
||||
...seriesResponse.data,
|
||||
series: [0, 1, 2].map((step) => ({
|
||||
timestamp: (startMs + step * 60_000) * 1000,
|
||||
value: 6e9,
|
||||
})),
|
||||
},
|
||||
});
|
||||
await wrapper.find('[data-test="profiles-run-query"]').trigger("click");
|
||||
await settle();
|
||||
expect(options().series[0].showSymbol).toBe(false);
|
||||
});
|
||||
|
||||
describe("URL and share link", () => {
|
||||
const shareUrl = (wrapper: ReturnType<typeof mountPage>) =>
|
||||
new URL(String(wrapper.findComponent({ name: "ShareButton" }).props("url")));
|
||||
@@ -1056,6 +1163,60 @@ describe("Profiles page", () => {
|
||||
expect(focusCalls).toHaveBeenCalledWith("root/0");
|
||||
});
|
||||
|
||||
it("drops the flame graph focus when a new run returns another tree", async () => {
|
||||
mocks.merge.mockResolvedValue(mergeResponse);
|
||||
route.query = {
|
||||
org_identifier: orgIdentifier,
|
||||
stream: "profiles-a",
|
||||
period: "15m",
|
||||
view: "flame",
|
||||
focus: "root/0",
|
||||
};
|
||||
focusCalls.mockClear();
|
||||
const wrapper = mountWithFlame();
|
||||
await settle();
|
||||
expect(focusCalls).toHaveBeenCalledWith("root/0");
|
||||
wrapper.findComponent({ name: "FlameGraphStub" }).vm.$emit("focus", "root/0/2");
|
||||
await flushPromises();
|
||||
expect(router.replace.mock.lastCall?.[0].query.focus).toBe("root/0/2");
|
||||
focusCalls.mockClear();
|
||||
|
||||
await wrapper.find('[data-test="profiles-run-query"]').trigger("click");
|
||||
await settle();
|
||||
|
||||
expect(wrapper.findComponent({ name: "FlameGraphStub" }).exists()).toBe(true);
|
||||
expect(focusCalls).not.toHaveBeenCalled();
|
||||
expect(router.replace.mock.lastCall?.[0].query).not.toHaveProperty("focus");
|
||||
expect(Object.fromEntries(shareUrl(wrapper).searchParams)).not.toHaveProperty("focus");
|
||||
});
|
||||
|
||||
it("drops a link's focus when metadata replaces the linked service and runs again", async () => {
|
||||
mocks.merge.mockResolvedValue(mergeResponse);
|
||||
route.query = {
|
||||
org_identifier: orgIdentifier,
|
||||
stream: "profiles-a",
|
||||
period: "15m",
|
||||
service_name: "service-gone",
|
||||
profile_type: "cpu",
|
||||
profile_unit: "nanoseconds",
|
||||
view: "flame",
|
||||
focus: "root/0",
|
||||
};
|
||||
// The linked run answers only after the second run started, so it is superseded.
|
||||
let answerLinkedRun!: (value: typeof mergeResponse) => void;
|
||||
mocks.merge.mockReturnValueOnce(new Promise((resolve) => (answerLinkedRun = resolve)));
|
||||
focusCalls.mockClear();
|
||||
mountWithFlame();
|
||||
await settle();
|
||||
answerLinkedRun(mergeResponse);
|
||||
await settle();
|
||||
|
||||
expect(mocks.merge).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.merge.mock.lastCall?.[2]).toMatchObject({ service_name: "service-a" });
|
||||
expect(focusCalls).not.toHaveBeenCalled();
|
||||
expect(router.replace.mock.lastCall?.[0].query).not.toHaveProperty("focus");
|
||||
});
|
||||
|
||||
it("shares the window of the last run, not of the first one", async () => {
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
@@ -1469,6 +1630,7 @@ describe("Profiles page", () => {
|
||||
expect(mocks.meta).toHaveBeenCalledWith(orgIdentifier, "profiles-b", {
|
||||
start_time: FROM,
|
||||
end_time: TO,
|
||||
service_name: "service-b",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1593,8 +1755,12 @@ describe("Profiles page", () => {
|
||||
(wrapper.find('[data-test="profiles-stream-select"] select').element as HTMLSelectElement)
|
||||
.value,
|
||||
).toBe("profiles-a");
|
||||
expect(mocks.meta).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.meta).toHaveBeenCalledWith(orgIdentifier, "profiles-a", window);
|
||||
expect(mocks.meta).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.meta).toHaveBeenNthCalledWith(1, orgIdentifier, "profiles-a", window);
|
||||
expect(mocks.meta).toHaveBeenNthCalledWith(2, orgIdentifier, "profiles-a", {
|
||||
...window,
|
||||
service_name: "service-a",
|
||||
});
|
||||
expect(mocks.merge).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.merge).toHaveBeenCalledWith(
|
||||
orgIdentifier,
|
||||
|
||||
@@ -76,8 +76,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
</span>
|
||||
</span>
|
||||
<OTooltip
|
||||
v-if="!queryLoading && (runPending || isAutoRunOn)"
|
||||
:content="runPending ? t('search.autoRunPendingDot') : t('search.autoRunEnabled')"
|
||||
v-if="!queryLoading && (!canQuery || runPending || isAutoRunOn)"
|
||||
:content="runButtonTooltip"
|
||||
/>
|
||||
</OButton>
|
||||
<ODropdown
|
||||
@@ -892,6 +892,8 @@ let initRunSeq = 0;
|
||||
// Holds the stream and date watchers off while a route seed is applied or dropped, so they cannot reset it or reload.
|
||||
let seeding = false;
|
||||
let lastRunSeeded = false;
|
||||
// The focus from a link belongs to the first run's tree; any later run draws a tree its index path may not match.
|
||||
let keepSeededFocus = false;
|
||||
// Set while a watcher reloads meta, which can pick a new service or type: the select watcher must not run a second query.
|
||||
let reloadingMeta = false;
|
||||
// Set by a drag on the chart: that is a direct action on the results, so it runs even with Auto Run off.
|
||||
@@ -939,6 +941,10 @@ const currentProfileType = computed(() => {
|
||||
const canQuery = computed(
|
||||
() => !!orgIdentifier.value && !!selectedStream.value && !!currentProfileType.value,
|
||||
);
|
||||
const runButtonTooltip = computed(() => {
|
||||
if (!canQuery.value) return t("profiles.runNeedsProfileType");
|
||||
return runPending.value ? t("search.autoRunPendingDot") : t("search.autoRunEnabled");
|
||||
});
|
||||
const hasProfileData = computed(() => (mergeResult.value?.total ?? 0) > 0);
|
||||
// The server keeps the most common stacks only; say how much of the data the views show.
|
||||
const sampledPercent = computed(() => {
|
||||
@@ -1011,6 +1017,7 @@ const seriesChartData = computed(() => {
|
||||
line.points.map((point) => [Math.floor(point.timestamp / 1000), seriesDisplayValue(point)]),
|
||||
);
|
||||
const seriesData = lineData.flat();
|
||||
const runRange = lastRun.value?.range;
|
||||
return {
|
||||
options: {
|
||||
// Tooltip, grid and y axis follow the Logs histogram (utils/logs/convertLogData.ts).
|
||||
@@ -1034,7 +1041,13 @@ const seriesChartData = computed(() => {
|
||||
dataZoom: { brushStyle: dataZoomBrushStyle(), show: true, yAxisIndex: "none" },
|
||||
},
|
||||
},
|
||||
xAxis: { type: "time", axisLabel: { hideOverlap: true } },
|
||||
// The axis spans the run's window, so one bucket is not stretched to fill the chart.
|
||||
xAxis: {
|
||||
type: "time",
|
||||
min: runRange ? Math.floor(runRange.startTime / 1000) : undefined,
|
||||
max: runRange ? Math.floor(runRange.endTime / 1000) : undefined,
|
||||
axisLabel: { hideOverlap: true },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
axisLine: { show: true },
|
||||
@@ -1053,7 +1066,8 @@ const seriesChartData = computed(() => {
|
||||
type: "line",
|
||||
name: line.label,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
// With symbols off, a lone point draws nothing, and two read as a bare segment.
|
||||
showSymbol: line.points.length < 3,
|
||||
color: line.color || undefined,
|
||||
lineStyle: { width: 2 },
|
||||
// An area under one line reads as volume; under several it hides the lines below.
|
||||
@@ -1411,6 +1425,7 @@ const resetProfilesState = () => {
|
||||
sandwichFunction.value = null;
|
||||
detailsName.value = null;
|
||||
flameFocus.value = null;
|
||||
keepSeededFocus = false;
|
||||
errorMessage.value = "";
|
||||
};
|
||||
|
||||
@@ -1558,26 +1573,33 @@ const loadMeta = async () => {
|
||||
if (!org || !stream || !range) return;
|
||||
const requestSeq = ++metaRequestSeq;
|
||||
const run = initRunSeq;
|
||||
try {
|
||||
const response = await profilesService.meta(org, stream, {
|
||||
const isStale = () => requestSeq !== metaRequestSeq || isStaleRun(run, org);
|
||||
// The server scopes only profile_types to service_name; services always lists every service.
|
||||
const fetchMeta = (service: string | null) =>
|
||||
profilesService.meta(org, stream, {
|
||||
start_time: range.startTime,
|
||||
end_time: range.endTime,
|
||||
...(service ? { service_name: service } : {}),
|
||||
});
|
||||
if (requestSeq !== metaRequestSeq || isStaleRun(run, org)) return;
|
||||
try {
|
||||
let service = selectedService.value;
|
||||
let response = await fetchMeta(service);
|
||||
if (isStale()) return;
|
||||
const services: string[] = response.data.services ?? [];
|
||||
// An empty window lists no services; keep the chosen one for when data comes back.
|
||||
if (services.length && (!service || !services.includes(service))) {
|
||||
service = services[0];
|
||||
response = await fetchMeta(service);
|
||||
if (isStale()) return;
|
||||
}
|
||||
metaResponse.value = response.data;
|
||||
const firstService = serviceOptions.value[0]?.value ?? null;
|
||||
// CPU first, as the Traces "View Profile" link does; the server lists types alphabetically (alloc_objects first).
|
||||
const firstProfileType =
|
||||
profileTypeOptions.value.find((option) => option.value.startsWith("cpu\u0000"))?.value ??
|
||||
profileTypeOptions.value[0]?.value ??
|
||||
null;
|
||||
|
||||
if (
|
||||
!selectedService.value ||
|
||||
!serviceOptions.value.some((item) => item.value === selectedService.value)
|
||||
) {
|
||||
selectedService.value = firstService;
|
||||
}
|
||||
selectedService.value = service;
|
||||
if (
|
||||
!selectedProfileType.value ||
|
||||
!profileTypeOptions.value.some((item) => item.value === selectedProfileType.value)
|
||||
@@ -1597,7 +1619,7 @@ const loadMeta = async () => {
|
||||
breakdownLabel.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestSeq !== metaRequestSeq || isStaleRun(run, org)) return;
|
||||
if (isStale()) return;
|
||||
errorMessage.value = buildErrorMessage(error);
|
||||
}
|
||||
};
|
||||
@@ -1640,6 +1662,9 @@ const runQuery = async () => {
|
||||
const range = resolveTimeRange();
|
||||
const payload = queryPayload(range);
|
||||
if (!org || !stream || !payload || !range) return;
|
||||
// Only the run started with the link's inputs may keep its focus, even if it is superseded or fails.
|
||||
const keepFocus = keepSeededFocus;
|
||||
keepSeededFocus = false;
|
||||
const runState = { query: pageQuery(), range, payload };
|
||||
const requestSeq = ++queryRequestSeq;
|
||||
const run = initRunSeq;
|
||||
@@ -1660,6 +1685,7 @@ const runQuery = async () => {
|
||||
if (requestSeq !== queryRequestSeq || isStaleRun(run, org)) return;
|
||||
seriesResponse.value = seriesResult.data;
|
||||
mergeResult.value = mergeResponseValue.data;
|
||||
if (!keepFocus) flameFocus.value = null;
|
||||
lastRun.value = runState;
|
||||
resetStackExpansion(mergeResponseValue.data.root);
|
||||
syncUrl();
|
||||
@@ -1862,6 +1888,7 @@ const initPage = async () => {
|
||||
sandwichFunction.value = seed.sandwichFunction;
|
||||
detailsName.value = seed.details;
|
||||
flameFocus.value = seed.focus;
|
||||
keepSeededFocus = !!seed.focus;
|
||||
breakdownLabel.value = seed.breakdown;
|
||||
compareState.value = seed.compare;
|
||||
}
|
||||
@@ -1896,8 +1923,10 @@ const reloadMeta = async () => {
|
||||
};
|
||||
|
||||
// A new service or profile type runs the query (Auto Run) or marks Run query pending.
|
||||
watch([selectedService, selectedProfileType], async () => {
|
||||
watch([selectedService, selectedProfileType], async ([service], [previousService]) => {
|
||||
if (seeding || reloadingMeta) return;
|
||||
// Profile types are scoped to the service; reloadMeta holds this watcher, so it cannot loop.
|
||||
if (service !== previousService) await reloadMeta();
|
||||
if (draftTagKey.value) {
|
||||
draftTagValue.value = null;
|
||||
await loadTagValues();
|
||||
|
||||
@@ -129,7 +129,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
<OButtonGroup
|
||||
v-if="showPinnedSavedViews"
|
||||
data-test="traces-search-bar-saved-views"
|
||||
class="element-box-shadow border-button-outline-border shrink-0 border p-0"
|
||||
class="border-button-outline-border shrink-0 border p-0"
|
||||
>
|
||||
<ODropdown
|
||||
:open="savedViewsDropdownOpen"
|
||||
@@ -328,7 +328,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
variant="ghost"
|
||||
data-test="traces-search-bar-cancel-btn"
|
||||
:title="t('search.cancel')"
|
||||
class="bg-cancel-query-bg! text-button-primary-foreground! element-box-shadow h-[1.875rem]! w-[5.875rem]! ![border-radius:0.375rem_0_0_0.375rem] p-0 px-1! py-0! text-center text-xs! leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,_opacity_0.2s_ease]"
|
||||
class="bg-cancel-query-bg! text-button-primary-foreground! h-[1.875rem]! w-[5.875rem]! ![border-radius:0.375rem_0_0_0.375rem] p-0 px-1! py-0! text-center text-xs! leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,_opacity_0.2s_ease]"
|
||||
@click="cancelQueryData"
|
||||
>{{ t("search.cancel") }}</OButton
|
||||
>
|
||||
@@ -338,7 +338,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
data-test="logs-search-bar-refresh-btn"
|
||||
data-cy="search-bar-refresh-button"
|
||||
:title="t('search.runQuery')"
|
||||
class="element-box-shadow bg-button-primary! text-button-primary-foreground! hover:ring-button-primary/70 h-[1.875rem]! w-[5.875rem]! p-0 px-1! py-0! text-center text-xs! leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,_opacity_0.2s_ease] hover:opacity-90 hover:ring-2"
|
||||
class="bg-button-primary! text-button-primary-foreground! hover:ring-button-primary/70 h-[1.875rem]! w-[5.875rem]! p-0 px-1! py-0! text-center text-xs! leading-4! font-medium! break-words whitespace-normal [transition:box-shadow_0.3s_ease,_opacity_0.2s_ease] hover:opacity-90 hover:ring-2"
|
||||
:class="
|
||||
store.state.zoConfig.auto_query_enabled
|
||||
? '![border-radius:0.375rem_0_0_0.375rem]'
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const rumInit = vi.hoisted(() => vi.fn());
|
||||
const rumSdk = vi.hoisted(() => ({
|
||||
setUser: vi.fn(),
|
||||
startSessionReplayRecording: vi.fn(),
|
||||
}));
|
||||
const logsInit = vi.hoisted(() => vi.fn());
|
||||
const getConfigFull = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@openobserve/browser-rum", () => ({ openobserveRum: { init: rumInit, ...rumSdk } }));
|
||||
vi.mock("@openobserve/browser-logs", () => ({ openobserveLogs: { init: logsInit } }));
|
||||
vi.mock("@/services/config", () => ({ default: { get_config_full: getConfigFull } }));
|
||||
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import store from "@/stores";
|
||||
import { resetBrowserRumForTests } from "./initBrowserRum";
|
||||
import { ensureBrowserRum } from "./ensureBrowserRum";
|
||||
|
||||
const RUM = {
|
||||
enabled: true,
|
||||
client_token: "token",
|
||||
application_id: "app",
|
||||
site: "rum.example.com",
|
||||
service: "web",
|
||||
env: "test",
|
||||
organization_identifier: "org",
|
||||
};
|
||||
|
||||
const USER = { email: "user@example.com", given_name: "Ada", family_name: "Lovelace" };
|
||||
|
||||
beforeEach(() => {
|
||||
resetBrowserRumForTests();
|
||||
queryClient.clear();
|
||||
rumInit.mockReset();
|
||||
logsInit.mockReset();
|
||||
Object.values(rumSdk).forEach((fn) => fn.mockReset());
|
||||
store.state.userInfo = USER;
|
||||
getConfigFull.mockReset();
|
||||
getConfigFull.mockResolvedValue({ data: { version: "v1", rum: RUM } });
|
||||
});
|
||||
|
||||
describe("ensureBrowserRum", () => {
|
||||
it("starts RUM from the org's full config and identifies the user without replay", async () => {
|
||||
await ensureBrowserRum("acme");
|
||||
|
||||
expect(getConfigFull).toHaveBeenCalledWith("acme");
|
||||
expect(rumInit).toHaveBeenCalledTimes(1);
|
||||
expect(logsInit).toHaveBeenCalledTimes(1);
|
||||
expect(rumSdk.setUser).toHaveBeenCalledWith({
|
||||
id: "user@example.com",
|
||||
name: "Ada Lovelace",
|
||||
email: "user@example.com",
|
||||
});
|
||||
expect(rumSdk.startSessionReplayRecording).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fetch the config again once RUM has started", async () => {
|
||||
await ensureBrowserRum("acme");
|
||||
await ensureBrowserRum("beta");
|
||||
|
||||
expect(getConfigFull).toHaveBeenCalledTimes(1);
|
||||
expect(rumInit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does nothing without an org", async () => {
|
||||
await ensureBrowserRum(undefined);
|
||||
await ensureBrowserRum("");
|
||||
|
||||
expect(getConfigFull).not.toHaveBeenCalled();
|
||||
expect(rumInit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("neither starts RUM nor sets the user when the full config has it disabled", async () => {
|
||||
getConfigFull.mockResolvedValue({ data: { version: "v1", rum: { ...RUM, enabled: false } } });
|
||||
|
||||
await ensureBrowserRum("acme");
|
||||
|
||||
expect(rumInit).not.toHaveBeenCalled();
|
||||
expect(rumSdk.setUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never rejects when the full config cannot be fetched", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
getConfigFull.mockRejectedValue(new Error("403"));
|
||||
|
||||
await expect(ensureBrowserRum("acme")).resolves.toBeUndefined();
|
||||
|
||||
expect(rumInit).not.toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { openobserveRum } from "@openobserve/browser-rum";
|
||||
import { queryClient } from "@/composables/query/queryClient";
|
||||
import { configFullQuery } from "@/services/config.queries";
|
||||
import store from "@/stores";
|
||||
import { initBrowserRum, isBrowserRumStarted, type BrowserRumConfig } from "./initBrowserRum";
|
||||
|
||||
/** Starts RUM on a signed-in page outside MainLayout from the org's full config; never rejects. */
|
||||
export const ensureBrowserRum = async (orgIdentifier: string | undefined): Promise<void> => {
|
||||
if (!orgIdentifier || isBrowserRumStarted()) return;
|
||||
try {
|
||||
const fullConfig: { rum?: BrowserRumConfig } | undefined = await queryClient.fetchQuery(
|
||||
configFullQuery(orgIdentifier),
|
||||
);
|
||||
if (!initBrowserRum(fullConfig?.rum, store.state.API_ENDPOINT)) return;
|
||||
const userInfo: { email?: string; given_name?: string; family_name?: string } =
|
||||
store.state.userInfo;
|
||||
// Standalone pages never record replay; only MainLayout and sign-in start it.
|
||||
openobserveRum.setUser({
|
||||
id: userInfo.email,
|
||||
name: userInfo.given_name + " " + userInfo.family_name,
|
||||
email: userInfo.email,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to start RUM:", error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BrowserRumConfig } from "./initBrowserRum";
|
||||
|
||||
const rumInit = vi.hoisted(() => vi.fn());
|
||||
const logsInit = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@openobserve/browser-rum", () => ({
|
||||
openobserveRum: { init: rumInit },
|
||||
}));
|
||||
vi.mock("@openobserve/browser-logs", () => ({ openobserveLogs: { init: logsInit } }));
|
||||
|
||||
const EXT = "chrome-extension://abcdefghijklmnop";
|
||||
|
||||
const RUM: BrowserRumConfig = {
|
||||
enabled: true,
|
||||
client_token: "token",
|
||||
application_id: "app",
|
||||
site: "rum.example.com",
|
||||
service: "web",
|
||||
env: "test",
|
||||
version: "v0.70.0",
|
||||
organization_identifier: "org",
|
||||
api_version: "v1",
|
||||
insecure_http: false,
|
||||
};
|
||||
|
||||
// The SDK may be initialised once per page load, so each case gets a fresh module.
|
||||
const loadHelper = async () => (await import("./initBrowserRum")).initBrowserRum;
|
||||
|
||||
const rumBeforeSend = (): ((event: Record<string, unknown>) => 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 },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
rumInit.mockReset();
|
||||
logsInit.mockReset();
|
||||
});
|
||||
|
||||
describe("initBrowserRum", () => {
|
||||
it("initialises the RUM and logs SDKs from the full config's rum block", async () => {
|
||||
const initBrowserRum = await loadHelper();
|
||||
|
||||
expect(initBrowserRum(RUM, "http://localhost:5080")).toBe(true);
|
||||
|
||||
expect(rumInit).toHaveBeenCalledTimes(1);
|
||||
expect(rumInit.mock.calls[0][0]).toMatchObject({
|
||||
applicationId: "app",
|
||||
clientToken: "token",
|
||||
site: "rum.example.com",
|
||||
organizationIdentifier: "org",
|
||||
service: "web",
|
||||
env: "test",
|
||||
version: "v0.70.0",
|
||||
apiVersion: "v1",
|
||||
insecureHTTP: false,
|
||||
startSessionReplayRecordingManually: true,
|
||||
});
|
||||
expect(logsInit).toHaveBeenCalledTimes(1);
|
||||
expect(logsInit.mock.calls[0][0]).toMatchObject({
|
||||
clientToken: "token",
|
||||
site: "rum.example.com",
|
||||
organizationIdentifier: "org",
|
||||
version: "v0.70.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("initialises only once when the full config arrives again for another org", async () => {
|
||||
const initBrowserRum = await loadHelper();
|
||||
|
||||
expect(initBrowserRum(RUM, "http://localhost:5080")).toBe(true);
|
||||
expect(initBrowserRum({ ...RUM }, "http://localhost:5080")).toBe(true);
|
||||
|
||||
expect(rumInit).toHaveBeenCalledTimes(1);
|
||||
expect(logsInit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["absent", undefined],
|
||||
["null", null],
|
||||
["disabled", { ...RUM, enabled: false }],
|
||||
])("does not initialise when rum is %s", async (_label, rum) => {
|
||||
const initBrowserRum = await loadHelper();
|
||||
|
||||
expect(initBrowserRum(rum, "http://localhost:5080")).toBe(false);
|
||||
|
||||
expect(rumInit).not.toHaveBeenCalled();
|
||||
expect(logsInit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never throws into the config path and still starts logs when the RUM SDK throws", async () => {
|
||||
const initBrowserRum = await loadHelper();
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
rumInit.mockImplementation(() => {
|
||||
throw new Error("rum boom");
|
||||
});
|
||||
|
||||
expect(() => initBrowserRum(RUM, "")).not.toThrow();
|
||||
|
||||
expect(logsInit).toHaveBeenCalledTimes(1);
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("reports RUM off and retries it on the next config after the RUM SDK throws", async () => {
|
||||
const initBrowserRum = await loadHelper();
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
rumInit.mockImplementationOnce(() => {
|
||||
throw new Error("rum boom");
|
||||
});
|
||||
|
||||
expect(initBrowserRum(RUM, "")).toBe(false);
|
||||
expect(initBrowserRum(RUM, "")).toBe(true);
|
||||
|
||||
expect(rumInit).toHaveBeenCalledTimes(2);
|
||||
expect(logsInit).toHaveBeenCalledTimes(1);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it.each([["client_token"], ["application_id"], ["site"]])(
|
||||
"does not start RUM without %s and starts it from a corrected config",
|
||||
async (field) => {
|
||||
const initBrowserRum = await loadHelper();
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
expect(initBrowserRum({ ...RUM, [field]: "" }, "")).toBe(false);
|
||||
expect(rumInit).not.toHaveBeenCalled();
|
||||
|
||||
expect(initBrowserRum(RUM, "")).toBe(true);
|
||||
expect(rumInit).toHaveBeenCalledTimes(1);
|
||||
expect(rumInit.mock.calls[0][0]).toMatchObject({
|
||||
applicationId: "app",
|
||||
clientToken: "token",
|
||||
});
|
||||
expect(logsInit).toHaveBeenCalledTimes(1);
|
||||
consoleError.mockRestore();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("initBrowserRum beforeSend", () => {
|
||||
beforeEach(async () => {
|
||||
(await loadHelper())(RUM, "");
|
||||
});
|
||||
|
||||
it("drops an error whose every frame is a Chrome extension", () => {
|
||||
const stack = `TypeError: boom\n at inject (${EXT}/content.js:1:1)\n at ${EXT}/x.js:2:2`;
|
||||
expect(rumBeforeSend()(errorEvent(stack))).toBe(false);
|
||||
});
|
||||
|
||||
it("sends an error with at least one app frame", () => {
|
||||
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(rumBeforeSend()(errorEvent(stack))).toBe(true);
|
||||
});
|
||||
|
||||
it("drops an error matching an ignored pattern", () => {
|
||||
const event = {
|
||||
type: "error",
|
||||
view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" },
|
||||
error: { message: "ResizeObserver loop completed with undelivered notifications", stack: "" },
|
||||
};
|
||||
expect(rumBeforeSend()(event)).toBe(false);
|
||||
});
|
||||
|
||||
it("still scrubs sign-in tokens from the view URL of an event it sends", () => {
|
||||
const event = {
|
||||
type: "view",
|
||||
view: { url: "https://cloud.openobserve.ai/web/cb#id_token=abc", referrer: "" },
|
||||
};
|
||||
expect(rumBeforeSend()(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", () => {
|
||||
const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2ln";
|
||||
const event = {
|
||||
type: "action",
|
||||
view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" },
|
||||
action: { target: { name: `Copy ${jwt}` } },
|
||||
};
|
||||
expect(rumBeforeSend()(event)).toBe(true);
|
||||
expect(event.action.target.name).toBe("Copy ");
|
||||
});
|
||||
|
||||
it("drops a log matching an ignored pattern and scrubs the view URL of one it sends", () => {
|
||||
const logsBeforeSend = logsInit.mock.calls[0][0].beforeSend as (
|
||||
log: Record<string, unknown>,
|
||||
) => boolean;
|
||||
expect(logsBeforeSend({ message: "ResizeObserver loop limit exceeded" })).toBe(false);
|
||||
const log = {
|
||||
message: "hello",
|
||||
view: { url: "https://cloud.openobserve.ai/web/cb#id_token=abc" },
|
||||
};
|
||||
expect(logsBeforeSend(log)).toBe(true);
|
||||
expect(log.view.url).toBe("https://cloud.openobserve.ai/web/cb#id_token=redacted");
|
||||
});
|
||||
|
||||
it("scrubs a public dashboard slug from a resource URL", () => {
|
||||
const event = {
|
||||
type: "resource",
|
||||
view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" },
|
||||
resource: { url: "https://cloud.openobserve.ai/api/public_dashboards/s3cr3t/panels" },
|
||||
};
|
||||
expect(rumBeforeSend()(event)).toBe(true);
|
||||
expect(event.resource.url).toBe(
|
||||
"https://cloud.openobserve.ai/api/public_dashboards/redacted/panels",
|
||||
);
|
||||
});
|
||||
|
||||
it("scrubs secrets from an error's message and resource URL", () => {
|
||||
const event = {
|
||||
type: "error",
|
||||
view: { url: "https://cloud.openobserve.ai/web/logs", referrer: "" },
|
||||
error: {
|
||||
message: "Failed to fetch https://cloud.openobserve.ai/web/cb#access_token=abc",
|
||||
stack: "",
|
||||
resource: { url: "https://cloud.openobserve.ai/api/public_dashboards/s3cr3t/data" },
|
||||
},
|
||||
};
|
||||
expect(rumBeforeSend()(event)).toBe(true);
|
||||
expect(event.error.message).toBe(
|
||||
"Failed to fetch https://cloud.openobserve.ai/web/cb#access_token=redacted",
|
||||
);
|
||||
expect(event.error.resource.url).toBe(
|
||||
"https://cloud.openobserve.ai/api/public_dashboards/redacted/data",
|
||||
);
|
||||
});
|
||||
|
||||
it("scrubs secrets from a log's referrer, HTTP URL and message", () => {
|
||||
const logsBeforeSend = logsInit.mock.calls[0][0].beforeSend as (
|
||||
log: Record<string, unknown>,
|
||||
) => boolean;
|
||||
const log = {
|
||||
message: "GET https://cloud.openobserve.ai/web/public/dashboards/s3cr3t failed",
|
||||
view: {
|
||||
url: "https://cloud.openobserve.ai/web/logs",
|
||||
referrer: "https://cloud.openobserve.ai/web/cb#id_token=abc",
|
||||
},
|
||||
http: { url: "https://cloud.openobserve.ai/api/public_dashboards/s3cr3t/data" },
|
||||
};
|
||||
expect(logsBeforeSend(log)).toBe(true);
|
||||
expect(log.view.referrer).toBe("https://cloud.openobserve.ai/web/cb#id_token=redacted");
|
||||
expect(log.http.url).toBe("https://cloud.openobserve.ai/api/public_dashboards/redacted/data");
|
||||
expect(log.message).toBe(
|
||||
"GET https://cloud.openobserve.ai/web/public/dashboards/redacted failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { openobserveRum } from "@openobserve/browser-rum";
|
||||
import { openobserveLogs } from "@openobserve/browser-logs";
|
||||
import { shouldPropagateTracing } from "./tracingOrigin";
|
||||
import { isIgnoredNoise } from "./ignoredNoisePatterns";
|
||||
import { scrubAuthParams } from "./scrubAuthParams";
|
||||
import { isExtensionOnlyError } from "./extensionFrames";
|
||||
|
||||
/** The `rum` block of the authenticated full config (`GET /api/{org}/config`). */
|
||||
export interface BrowserRumConfig {
|
||||
enabled: boolean;
|
||||
client_token: string;
|
||||
application_id: string;
|
||||
site: string;
|
||||
service: string;
|
||||
env: string;
|
||||
version?: string;
|
||||
organization_identifier: string;
|
||||
api_version?: string;
|
||||
insecure_http?: boolean;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
const IGNORED_ERROR_PATTERNS = [
|
||||
/ResizeObserver loop/i,
|
||||
/reo.dev/i,
|
||||
/Cannot set properties of null \(setting 'innerHTML'\)/,
|
||||
];
|
||||
|
||||
const IGNORED_LOG_PATTERNS = [/ResizeObserver loop/i];
|
||||
|
||||
let rumStarted = false;
|
||||
let logsStarted = false;
|
||||
|
||||
/** True once the RUM SDK has started in this page load. */
|
||||
export const isBrowserRumStarted = (): boolean => rumStarted;
|
||||
|
||||
/** Starts the browser RUM and logs SDKs at most once per page load; returns whether RUM is on. Never throws. */
|
||||
export const initBrowserRum = (
|
||||
rum: BrowserRumConfig | null | undefined,
|
||||
apiEndpoint: string,
|
||||
): boolean => {
|
||||
if (!rum?.enabled) return false;
|
||||
if (rumStarted && logsStarted) return true;
|
||||
|
||||
const version = rum.version || "0.0.1";
|
||||
const apiVersion = rum.api_version || "v1";
|
||||
const insecureHTTP = rum.insecure_http || false;
|
||||
// Without a token or application id the SDK drops the init, and without a site it sends to its own default intake.
|
||||
const hasLogsSettings = Boolean(rum.client_token && rum.site);
|
||||
const hasRumSettings = hasLogsSettings && Boolean(rum.application_id);
|
||||
if (!rumStarted && hasRumSettings) {
|
||||
rumStarted = tryInit("RUM", () =>
|
||||
startRumSdk(rum, apiEndpoint, version, apiVersion, insecureHTTP),
|
||||
);
|
||||
} else if (!rumStarted) {
|
||||
console.error("The RUM configuration lacks a client token, application id or site.");
|
||||
}
|
||||
if (!logsStarted && hasLogsSettings) {
|
||||
logsStarted = tryInit("logs", () => startLogsSdk(rum, version, apiVersion, insecureHTTP));
|
||||
}
|
||||
return rumStarted;
|
||||
};
|
||||
|
||||
export const resetBrowserRumForTests = (): void => {
|
||||
rumStarted = false;
|
||||
logsStarted = false;
|
||||
};
|
||||
|
||||
const tryInit = (sdk: string, init: () => void): boolean => {
|
||||
try {
|
||||
init();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to start the browser ${sdk} SDK:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const startRumSdk = (
|
||||
rum: BrowserRumConfig,
|
||||
apiEndpoint: string,
|
||||
version: string,
|
||||
apiVersion: string,
|
||||
insecureHTTP: boolean,
|
||||
): void =>
|
||||
openobserveRum.init({
|
||||
applicationId: rum.application_id,
|
||||
clientToken: rum.client_token,
|
||||
site: rum.site,
|
||||
organizationIdentifier: rum.organization_identifier,
|
||||
service: rum.service,
|
||||
env: rum.env,
|
||||
version,
|
||||
trackResources: true,
|
||||
trackLongTasks: true,
|
||||
trackUserInteractions: true,
|
||||
actionNameAttribute: "data-test",
|
||||
apiVersion,
|
||||
insecureHTTP,
|
||||
defaultPrivacyLevel: "allow",
|
||||
// Session renewal drops the forced replay start, so sampling must keep every renewed session recording.
|
||||
sessionReplaySampleRate: 100,
|
||||
startSessionReplayRecordingManually: true,
|
||||
// Cross-origin, the injected headers fail the CORS preflight and kill every API call.
|
||||
allowedTracingUrls: shouldPropagateTracing(apiEndpoint, window.location.origin)
|
||||
? [
|
||||
{
|
||||
match: apiEndpoint + "/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, "");
|
||||
}
|
||||
if (event.type === "error") {
|
||||
const errorMessage = event.error?.message || "";
|
||||
const errorStack = event.error?.stack || "";
|
||||
const shouldIgnore = IGNORED_ERROR_PATTERNS.some(
|
||||
(pattern) => pattern.test(errorMessage) || pattern.test(errorStack),
|
||||
);
|
||||
if (shouldIgnore || isIgnoredNoise(errorMessage, errorStack)) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const startLogsSdk = (
|
||||
rum: BrowserRumConfig,
|
||||
version: string,
|
||||
apiVersion: string,
|
||||
insecureHTTP: boolean,
|
||||
): void =>
|
||||
openobserveLogs.init({
|
||||
clientToken: rum.client_token,
|
||||
site: rum.site,
|
||||
organizationIdentifier: rum.organization_identifier,
|
||||
service: rum.service,
|
||||
env: rum.env,
|
||||
version,
|
||||
forwardErrorsToLogs: true,
|
||||
insecureHTTP,
|
||||
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;
|
||||
const logMessage = log.message || "";
|
||||
const shouldIgnore = IGNORED_LOG_PATTERNS.some((pattern) => pattern.test(logMessage));
|
||||
return !(shouldIgnore || isIgnoredNoise(logMessage));
|
||||
},
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { createRouter, createWebHistory } from "vue-router";
|
||||
import { createStore } from "vuex";
|
||||
import organizationsService from "@/services/organizations";
|
||||
import awsMarketplace from "@/services/awsMarketplace";
|
||||
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
|
||||
|
||||
// Mock the services the two card-forms drive on submit.
|
||||
vi.mock("@/services/organizations", async (importOriginal) => {
|
||||
@@ -32,6 +33,7 @@ vi.mock("@/services/organizations", async (importOriginal) => {
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() }));
|
||||
vi.mock("@/services/awsMarketplace", () => ({
|
||||
default: {
|
||||
linkSubscription: vi.fn(),
|
||||
@@ -273,6 +275,12 @@ describe("AwsMarketplaceSetup", () => {
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
it("starts RUM from the first org the caller belongs to", async () => {
|
||||
await mountSelectOrg([{ identifier: "org1", name: "Org 1" }]);
|
||||
|
||||
expect(ensureBrowserRum).toHaveBeenLastCalledWith("org1");
|
||||
});
|
||||
|
||||
it("create form: blocks submit + does NOT create an org when newOrgName is empty", async () => {
|
||||
const wrapper = await mountSelectOrg();
|
||||
const createForm = wrapper.findAllComponents({ name: "OForm" })[0];
|
||||
|
||||
@@ -197,6 +197,7 @@ import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { getImageURL, useLocalOrganization } from "@/utils/zincutils";
|
||||
import awsMarketplace from "@/services/awsMarketplace";
|
||||
import organizationsService from "@/services/organizations";
|
||||
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
|
||||
@@ -265,6 +266,8 @@ export default defineComponent({
|
||||
|
||||
// Fetch user's organizations
|
||||
await fetchOrganizations();
|
||||
// RUM settings are instance-wide, so any org the user can read serves them.
|
||||
void ensureBrowserRum(eligibleOrganizations.value[0]?.identifier);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createRouter, createWebHistory } from "vue-router";
|
||||
import { createStore } from "vuex";
|
||||
import organizationsService from "@/services/organizations";
|
||||
import azureMarketplace from "@/services/azureMarketplace";
|
||||
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
|
||||
|
||||
// Mock the services the two card-forms drive on submit.
|
||||
vi.mock("@/services/organizations", async (importOriginal) => {
|
||||
@@ -32,6 +33,7 @@ vi.mock("@/services/organizations", async (importOriginal) => {
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() }));
|
||||
vi.mock("@/services/azureMarketplace", () => ({
|
||||
default: {
|
||||
linkSubscription: vi.fn(),
|
||||
@@ -259,6 +261,12 @@ describe("AzureMarketplaceSetup", () => {
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
it("starts RUM from the first org the caller belongs to", async () => {
|
||||
await mountSelectOrg([{ identifier: "org1", name: "Org 1" }]);
|
||||
|
||||
expect(ensureBrowserRum).toHaveBeenLastCalledWith("org1");
|
||||
});
|
||||
|
||||
it("create form: blocks submit + does NOT create an org when newOrgName is empty", async () => {
|
||||
const wrapper = await mountSelectOrg();
|
||||
const createForm = wrapper.findAllComponents({ name: "OForm" })[0];
|
||||
|
||||
@@ -198,6 +198,7 @@ import { raw, useI18nTyped } from "@/types/i18n";
|
||||
import { getImageURL, useLocalOrganization } from "@/utils/zincutils";
|
||||
import azureMarketplace from "@/services/azureMarketplace";
|
||||
import organizationsService from "@/services/organizations";
|
||||
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OIcon from "@/lib/core/Icon/OIcon.vue";
|
||||
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
|
||||
@@ -247,6 +248,8 @@ export default defineComponent({
|
||||
|
||||
// Fetch user's organizations
|
||||
await fetchOrganizations();
|
||||
// RUM settings are instance-wide, so any org the user can read serves them.
|
||||
void ensureBrowserRum(eligibleOrganizations.value[0]?.identifier);
|
||||
});
|
||||
|
||||
const fetchOrganizations = async () => {
|
||||
|
||||
@@ -22,10 +22,12 @@ import { makeSlackStore, ok } from "@/test/unit/helpers/slack";
|
||||
import { isSlackOAuthCallbackMessage } from "@/utils/slackOAuth";
|
||||
import { createStore } from "vuex";
|
||||
import organizations from "@/services/organizations";
|
||||
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
|
||||
import SlackConnectCallback from "./SlackConnectCallback.vue";
|
||||
|
||||
vi.mock("@/services/slack", () => ({ default: { confirmConnection: vi.fn() } }));
|
||||
vi.mock("@/services/organizations", () => ({ default: { list: vi.fn() } }));
|
||||
vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() }));
|
||||
|
||||
const refusal = (status: number, data: Record<string, unknown> = {}) =>
|
||||
Object.assign(new Error("refused"), {
|
||||
@@ -70,6 +72,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SlackConnectCallback", () => {
|
||||
it("starts RUM from the full config of the org this page resolved", async () => {
|
||||
await mountCallback();
|
||||
expect(ensureBrowserRum).toHaveBeenCalledWith("acme");
|
||||
});
|
||||
|
||||
it("in a popup, posts {type, code, state} to its same-origin opener and closes", async () => {
|
||||
const opener = { postMessage: vi.fn() };
|
||||
setOpener(opener);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useMutation } from "@tanstack/vue-query";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import SlackLinkCard from "@/components/slack/SlackLinkCard.vue";
|
||||
import { useSlackLinkOrg } from "@/composables/slack/useSlackLinkOrg";
|
||||
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import type { IllustrationName } from "@/lib/core/EmptyState/illustrations";
|
||||
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
@@ -128,6 +129,7 @@ const openSettings = (): void => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
whenResolved(() => void ensureBrowserRum(org.value));
|
||||
const error = queryString(route.query.error);
|
||||
const code = queryString(route.query.code);
|
||||
const state = queryString(route.query.state);
|
||||
|
||||
@@ -22,12 +22,14 @@ import organizations from "@/services/organizations";
|
||||
import slack from "@/services/slack";
|
||||
import { leaveFor, recallLinkRequest } from "@/utils/slackLink";
|
||||
import { axiosError, makeSlackStore, ok } from "@/test/unit/helpers/slack";
|
||||
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
|
||||
import SlackLink from "./SlackLink.vue";
|
||||
|
||||
vi.mock("@/services/slack", () => ({
|
||||
default: { startLinkRequest: vi.fn(), resendLinkRequest: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/services/organizations", () => ({ default: { list: vi.fn() } }));
|
||||
vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() }));
|
||||
vi.mock("@/utils/slackLink", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/utils/slackLink")>()),
|
||||
leaveFor: vi.fn(),
|
||||
@@ -77,6 +79,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SlackLink (N7 pass-through)", () => {
|
||||
it("starts RUM from the full config of the org this page resolved", async () => {
|
||||
await mountLink();
|
||||
expect(ensureBrowserRum).toHaveBeenCalledWith("acme");
|
||||
});
|
||||
|
||||
it("starts the request with the signature from ?r and hands off to Sign in with Slack", async () => {
|
||||
const w = await mountLink();
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useRoute } from "vue-router";
|
||||
import SlackLinkCard from "@/components/slack/SlackLinkCard.vue";
|
||||
import SlackLinkRefusal from "@/components/slack/SlackLinkRefusal.vue";
|
||||
import { useSlackLinkOrg } from "@/composables/slack/useSlackLinkOrg";
|
||||
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
|
||||
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
import { resendLinkRequestMutation, startLinkRequestMutation } from "@/services/slack.queries";
|
||||
import { useI18nTyped } from "@/types/i18n";
|
||||
@@ -89,7 +90,10 @@ const sendNewLink = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => whenResolved(begin));
|
||||
onMounted(() => {
|
||||
whenResolved(() => void ensureBrowserRum(org.value));
|
||||
whenResolved(begin);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -22,12 +22,14 @@ import { leaveFor, recallLinkRequest, rememberLinkRequest } from "@/utils/slackL
|
||||
import { makeSlackStore, ok } from "@/test/unit/helpers/slack";
|
||||
import { createStore } from "vuex";
|
||||
import organizations from "@/services/organizations";
|
||||
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
|
||||
import SlackLinkCallback from "./SlackLinkCallback.vue";
|
||||
|
||||
vi.mock("@/services/slack", () => ({
|
||||
default: { confirmLinkRequest: vi.fn(), resendLinkRequest: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/services/organizations", () => ({ default: { list: vi.fn() } }));
|
||||
vi.mock("@/utils/rum/ensureBrowserRum", () => ({ ensureBrowserRum: vi.fn() }));
|
||||
vi.mock("@/utils/slackLink", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/utils/slackLink")>()),
|
||||
leaveFor: vi.fn(),
|
||||
@@ -88,6 +90,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SlackLinkCallback", () => {
|
||||
it("starts RUM from the full config of the org this page resolved", async () => {
|
||||
await mountCallback();
|
||||
expect(ensureBrowserRum).toHaveBeenCalledWith("acme");
|
||||
});
|
||||
|
||||
it("confirms the code, shows Linked as, then strips the query and forgets the request", async () => {
|
||||
rememberLinkRequest("req-1.sig", "#help");
|
||||
const replaceState = vi.spyOn(window.history, "replaceState");
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import SlackLinkCard from "@/components/slack/SlackLinkCard.vue";
|
||||
import SlackLinkRefusal from "@/components/slack/SlackLinkRefusal.vue";
|
||||
import { useSlackLinkOrg } from "@/composables/slack/useSlackLinkOrg";
|
||||
import { ensureBrowserRum } from "@/utils/rum/ensureBrowserRum";
|
||||
import OButton from "@/lib/core/Button/OButton.vue";
|
||||
import OPageLayout from "@/lib/core/PageLayout/OPageLayout.vue";
|
||||
import { confirmLinkRequestMutation, resendLinkRequestMutation } from "@/services/slack.queries";
|
||||
@@ -114,6 +115,7 @@ const sendNewLink = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
whenResolved(() => void ensureBrowserRum(org.value));
|
||||
const code = queryString(route.query.code);
|
||||
const state = queryString(route.query.state);
|
||||
if (queryString(route.query.error)) {
|
||||
|
||||
Reference in New Issue
Block a user