feat: recent searches on Logs, Traces and Metrics (#15912)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Abhay Padamani
2026-10-30 11:21:16 +00:00
committed by GitHub
co-authored by github-actions[bot]
parent aaf70b0744
commit 3b2eca2d42
111 changed files with 14710 additions and 1056 deletions
+71 -4
View File
@@ -20776,6 +20776,72 @@
"unitPerSecond": "71f1adcb645b5392",
"updateLimit": "0fc43ae1b62fbe3e"
},
"recentSearches": {
"aboutKeptDays": "31640629384cbf7e",
"aboutKeptForever": "2e08559121a01fa6",
"allPages": "903542125c003f8f",
"allStreams": "7ba01d16e615fc96",
"clear": "0d0ff3d78c31ee1a",
"clearBodyAll": "3979c4c7803210eb",
"clearBodyPage": "51a8842d6e7464e8",
"confirmClear": "496121494e4a967a",
"copyFunction": "6fb093137db6056d",
"copyLink": "dbf362d4f210c780",
"copyQuery": "8e5a75e93167b5a0",
"datePlaceholder": "038e472923ed356d",
"deletedQuery": "6d7f86f5dc466780",
"emptyTitle": "3dca22657bdc4672",
"failed": "031a8f0f659df890",
"failedCount": "cd8f5051cba3686a",
"filterByDateRun": "e78d12f2d00398bd",
"functionCopied": "2f4b48e8061978cc",
"functionLabel": "350c73d410d6a3bc",
"includeStarred": "3e622ea859a8790a",
"keyHints": "ed540677df5e80bc",
"lineCount": "e7b8886bd3dbee6c",
"linkCopied": "d12860c21e78baa5",
"loadError": "bee6fadd3aefbbe7",
"loadMore": "ac8991ef01019cf5",
"loadOnly": "d25f6d7876fb7647",
"loadToFix": "529d4a9b26d98d6b",
"noMatch": "074aee6c434f78e0",
"page": {
"dbm": "fa7fe67124e94375",
"logs": "ea2100dc89ae9fe2",
"metrics": "a58da793c7250f1b",
"profiles": "535e52e4a2616ebe",
"traces": "998741a58ed2b411"
},
"pageTitle": {
"dbm": "228c84b5a56e6bce",
"logs": "21a9d8d924e102cd",
"metrics": "30219f1c45b8c972",
"profiles": "228c84b5a56e6bce",
"traces": "e1dc9727e9f50873"
},
"queryCopied": "2b5f7fe902cb48bc",
"ranCount": "e505811bceef8aa8",
"recentRow": "7cb59bfc93961fa0",
"relativeWindow": "e35e9688dee4f7ee",
"rerunNow": "22de87ddeda82846",
"rerunOriginal": "781be17093338815",
"reset": "645982c52b7c11ae",
"resetFilters": "10afa98480f2d06c",
"retry": "942087cc2d41e013",
"searchPlaceholder": "7d854a995bf7dc35",
"star": "e357d396871d1012",
"starredOnly": "fbee73c784a49660",
"statusAll": "ac78229d6b822ce5",
"statusError": "031a8f0f659df890",
"statusOk": "6d9a6f97a5fdaf3b",
"title": "228c84b5a56e6bce",
"today": "2b065c7c9ce466e5",
"undo": "a8283ade31856f71",
"unstar": "318e826a75161b9f",
"viewAll": "30a64216eaeac5d3",
"warning": "d0f76e2e3abbb739",
"yesterday": "566181254b293aa6"
},
"refreshButton": {
"dataFresh": "7c21db7765d42a41",
"dataGettingStale": "4854378d5d270821",
@@ -22601,6 +22667,7 @@
"updateSavedView": "f0e9e5ac3af3b640",
"updateSavedViewConfirm": "165c5f6398bd57f9",
"updateSavedViewWithCurrent": "9ec1d2751c4654c4",
"usageCostHistory": "b97c1a6d83f75800",
"userDefinedSchemaLabel": "ad895e214ecd7872",
"viewCreatedSuccessfully": "6a620ddf5d4013a9",
"viewDeletedSuccessfully": "6cc6f04153415865",
@@ -22615,11 +22682,11 @@
"webUrlNotConfigured": "da2e1df8a17952bc"
},
"search_history": {
"delayMessage": "33ba3ca60b3d278e",
"delayMessage": "b61c3cadbb0996d7",
"executed_at": "5f3ff136b10e28d5",
"get_history": "0cccb92604f6a427",
"sql_query": "9a4f1370f2b72c82",
"title": "66d7e4a1c738f0b1"
"title": "b97c1a6d83f75800"
},
"search_scheduler_job": {
"actions": "ff8059dc6752afdd",
@@ -23440,7 +23507,7 @@
"logsRowOpen": "4e62957ded0d0dd0",
"logsRunQuery": "0b9252e840f32989",
"logsSaveView": "af29df0f9ca1446c",
"logsSearchHistory": "145dd6f1992eb214",
"logsSearchHistory": "8962685dd0fd843e",
"logsToggleHistogram": "bebe67d66ebc5b81",
"logsToggleSidebar": "9cf94e5f212e1985",
"metricsAddToDashboard": "81c5e9c7bb53cc85",
@@ -23580,7 +23647,7 @@
"runningQueries": "a026d17f06f54cec",
"scoreConfigs": "c3c78db2263139e4",
"scorers": "966ddbb027958248",
"searchHistory": "66d7e4a1c738f0b1",
"searchHistory": "b97c1a6d83f75800",
"searchSchedulers": "047df1ad4dee93bc",
"sessions": "6fa3cbf451b2a1d5",
"sourceMaps": "74e923a5b4614cf6",
+24 -1
View File
@@ -1378,7 +1378,7 @@ pub fn service_routes() -> Router {
.route("/{org_id}/savedviews", get(search::saved_view::get_views).post(search::saved_view::create_view))
.route("/{org_id}/savedviews/{view_id}", get(search::saved_view::get_view).put(search::saved_view::update_view).delete(search::saved_view::delete_view))
.route("/{org_id}/query_history", get(query_history::list).post(query_history::record).layer(DefaultBodyLimit::max(query_history::MAX_BODY_BYTES)))
.route("/{org_id}/query_history", get(query_history::list).post(query_history::record).delete(query_history::clear).layer(DefaultBodyLimit::max(query_history::MAX_BODY_BYTES)))
.route("/{org_id}/query_history/{id}", patch(query_history::star).delete(query_history::delete).layer(DefaultBodyLimit::max(query_history::MAX_BODY_BYTES)))
// Functions
@@ -4358,6 +4358,29 @@ mod tests {
);
}
#[test]
fn query_history_list_publishes_both_response_shapes() {
let spec = serde_json::to_value(super::openapi::ApiDoc::openapi()).unwrap();
let ok = &spec["paths"]["/api/{org_id}/query_history"]["get"]["responses"]["200"];
assert_eq!(
ok["content"]["application/json"]["schema"]["$ref"],
"#/components/schemas/QueryHistoryListResponse"
);
let shapes = spec["components"]["schemas"]["QueryHistoryListResponse"]["oneOf"]
.as_array()
.expect("the list response is a oneOf");
let shapes = serde_json::to_string(shapes).unwrap();
assert!(
shapes.contains("#/components/schemas/QueryHistoryEntry"),
"{shapes}"
);
assert!(shapes.contains("\"type\":\"array\""), "{shapes}");
assert!(
shapes.contains("#/components/schemas/QueryHistoryCountResponse"),
"{shapes}"
);
}
#[cfg(feature = "enterprise")]
#[test]
fn experiment_lifecycle_actions_are_published_in_the_openapi_surface() {
@@ -120,6 +120,7 @@ use crate::{
openobserve_api_search::search::saved_view::update_view,
openobserve_api_management::request::query_history::record,
openobserve_api_management::request::query_history::list,
openobserve_api_management::request::query_history::clear,
openobserve_api_management::request::query_history::star,
openobserve_api_management::request::query_history::delete,
openobserve_api_management::request::folders::delete_folder,
@@ -698,6 +699,9 @@ use crate::{
openobserve_api_management::request::query_history::QueryHistoryRequest,
openobserve_api_management::request::query_history::QueryHistoryStarRequest,
openobserve_api_management::request::query_history::QueryHistoryEntry,
openobserve_api_management::request::query_history::QueryHistoryCountResponse,
openobserve_api_management::request::query_history::QueryHistoryListResponse,
openobserve_api_management::request::query_history::QueryHistoryClearResponse,
meta::user::UpdateUser,
meta::user::UserRoleRequest,
meta::user::PostUserRequest,
@@ -1618,6 +1622,7 @@ mod tests {
for (path, method) in [
("/api/{org_id}/query_history", "post"),
("/api/{org_id}/query_history", "get"),
("/api/{org_id}/query_history", "delete"),
("/api/{org_id}/query_history/{id}", "patch"),
("/api/{org_id}/query_history/{id}", "delete"),
] {
+622 -98
View File
@@ -21,7 +21,10 @@ use axum::{
};
use infra::table::{
entity::query_history::Model,
query_history::{self, MAX_STARRED_PER_USER, SetStarred},
query_history::{
self, DEFAULT_SOURCE, ListFilter, MAX_STARRED_PER_USER, NewRun, SOURCES, STATUS_ERROR,
STATUS_OK, SetStarred,
},
};
use openobserve_api_common::extractors::Headers;
use openobserve_core::auth::UserEmail;
@@ -34,14 +37,27 @@ use crate::common::meta::http::HttpResponse as MetaHttpResponse;
pub const MAX_BODY_BYTES: usize = 128 * 1024;
const MAX_QUERY_BYTES: usize = 16 * 1024;
const MAX_CONTEXT_BYTES: usize = 64 * 1024;
const MAX_STREAMS: usize = 50;
const DEFAULT_LIMIT: u64 = 50;
const MAX_LIMIT: u64 = 200;
#[derive(Deserialize, Serialize, ToSchema)]
pub struct QueryHistoryRequest {
/// `logs`, `traces`, `metrics`, `dbm` or `profiles`; defaults to `metrics`.
#[serde(default)]
pub source: Option<String>,
/// The query text, at most 16 KB and not blank.
pub query: String,
/// Time range, step, chart type and the panel data that reopens the query, at most 64 KB.
/// Runs with the same source and key merge into one entry; defaults to the query text.
#[serde(default)]
pub query_key: Option<String>,
/// Streams the query ran on, at most 50; each name non-empty and without a comma.
#[serde(default)]
pub streams: Vec<String>,
/// `ok` or `error`; defaults to `ok`.
#[serde(default)]
pub status: Option<String>,
/// What the page needs to restore the run, at most 64 KB; stored as given.
pub context: serde_json::Value,
}
@@ -55,24 +71,43 @@ pub struct QueryHistoryStarRequest {
pub struct QueryHistoryEntry {
/// Entry id.
pub id: String,
/// Page the query ran on.
pub source: String,
/// The query text.
pub query: String,
/// Time range, step, chart type and the panel data that reopens the query.
/// Hex sha256 of the merge key; null on entries recorded before keys existed.
pub query_key: Option<String>,
/// Streams the query ran on.
pub streams: Vec<String>,
/// What the page needs to restore the run.
pub context: serde_json::Value,
/// `ok` or `error`, from the latest run; `ok` on entries recorded by v1.1.x.
pub status: String,
/// Starred entries are kept until deleted.
pub starred: bool,
/// When the query was last run, in microseconds since the epoch.
/// How many runs merged into this entry; 1 on entries recorded by v1.1.x.
pub run_count: i64,
/// First run, in microseconds since the epoch; the last run on entries recorded by v1.1.x.
pub created_at: i64,
/// Latest run, in microseconds since the epoch.
pub updated_at: i64,
}
impl From<Model> for QueryHistoryEntry {
fn from(m: Model) -> Self {
Self {
streams: query_history::decode_streams(&m.streams),
id: m.id,
source: m.source,
query: m.query,
query_key: m.query_key,
context: m.context,
status: m.status,
starred: m.starred,
run_count: m.run_count,
created_at: m.created_at,
// A v1.1.x node in a rolling upgrade writes updated_at = 0; only the response is fixed.
updated_at: m.updated_at.max(m.created_at),
}
}
}
@@ -80,18 +115,55 @@ impl From<Model> for QueryHistoryEntry {
#[derive(Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct QueryHistoryListParams {
/// Comma list of sources (logs, traces, metrics, dbm, profiles); omitted means metrics.
pub source: Option<String>,
/// `ok` or `error`.
pub status: Option<String>,
/// Exact stream name the entry ran on.
pub stream: Option<String>,
/// Case-insensitive substring of the query text or the stream names.
pub q: Option<String>,
/// Only starred (`true`) or only unstarred (`false`) entries.
pub starred: Option<bool>,
/// Substring match on the query text.
pub q: Option<String>,
/// Entries last run at or after this time, in microseconds.
pub ran_from: Option<i64>,
/// Entries last run at or before this time, in microseconds.
pub ran_to: Option<i64>,
/// Defaults to 50, at most 200.
pub limit: Option<u64>,
/// Entries to skip, for paging. Defaults to 0.
/// Entries to skip, for paging. Defaults to 0, at most 9223372036854775807.
pub offset: Option<u64>,
/// Answer `{"count": n}`, the number of entries the filters match, instead of the entries.
pub count_only: Option<bool>,
}
fn list_limit(limit: Option<u64>) -> u64 {
limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
#[derive(Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct QueryHistoryClearParams {
/// One source or a comma-separated list; all sources when omitted.
pub source: Option<String>,
/// Also delete starred entries. Defaults to false.
pub include_starred: Option<bool>,
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct QueryHistoryCountResponse {
/// Entries matching the filters, ignoring limit and offset.
pub count: u64,
}
/// The entries, or with `count_only=true` how many entries match.
#[derive(Serialize, ToSchema)]
#[serde(untagged)]
pub enum QueryHistoryListResponse {
Entries(Vec<QueryHistoryEntry>),
Count(QueryHistoryCountResponse),
}
#[derive(Deserialize, Serialize, ToSchema)]
pub struct QueryHistoryClearResponse {
/// Number of entries deleted.
pub deleted: u64,
}
/// RecordQueryHistory
@@ -103,7 +175,7 @@ fn list_limit(limit: Option<u64>) -> u64 {
tag = "Query History",
operation_id = "RecordQueryHistory",
summary = "Record a query history entry",
description = "Records a query the caller ran. Running the same query as the caller's latest entry refreshes that entry instead of adding one. Stores nothing and answers 204 when ZO_QUERY_HISTORY_ENABLED is false.",
description = "Records a query the caller ran. A run with the same source and query_key as an existing entry of the caller merges into it: the run count goes up and the latest run time, context and status replace the old ones. Stores nothing and answers 204 when ZO_QUERY_HISTORY_ENABLED is false.",
security(
("Authorization"= [])
),
@@ -114,7 +186,7 @@ fn list_limit(limit: Option<u64>) -> u64 {
responses(
(status = 200, description = "Success", content_type = "application/json", body = QueryHistoryEntry),
(status = 204, description = "Query history is disabled; nothing was stored"),
(status = 400, description = "Query empty, or query or context too large", content_type = "application/json", body = ()),
(status = 400, description = "Unknown source or status, query empty, query or context too large, too many streams, or an empty stream name or one with a comma (the message lists them)", content_type = "application/json", body = ()),
(status = 413, description = "Request body larger than 128 KB"),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
@@ -146,7 +218,7 @@ pub async fn record(
tag = "Query History",
operation_id = "ListQueryHistory",
summary = "List query history",
description = "Lists the caller's query history, newest first. Empty when ZO_QUERY_HISTORY_ENABLED is false.",
description = "Lists the caller's query history, most recently run first. With count_only=true it answers {\"count\": n} (QueryHistoryCountResponse), the number of entries the filters match. Empty, or a count of 0, when ZO_QUERY_HISTORY_ENABLED is false.",
security(
("Authorization"= [])
),
@@ -155,7 +227,8 @@ pub async fn record(
QueryHistoryListParams,
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Vec<QueryHistoryEntry>),
(status = 200, description = "The entries, or with count_only=true the count", content_type = "application/json", body = QueryHistoryListResponse),
(status = 400, description = "Unknown source or status, or an offset above 9223372036854775807", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -177,6 +250,49 @@ pub async fn list(
.await
}
/// ClearQueryHistory
#[utoipa::path(
delete,
path = "/{org_id}/query_history",
context_path = "/api",
tag = "Query History",
operation_id = "ClearQueryHistory",
summary = "Clear the caller's query history",
description = "Deletes the caller's query history entries for the given sources, or all sources. Starred entries are kept unless include_starred is true.",
security(
("Authorization"= [])
),
params(
("org_id" = String, Path, description = "Organization name"),
QueryHistoryClearParams,
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = QueryHistoryClearResponse),
(status = 400, description = "Unknown source", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Query History", "operation": "delete"})),
("x-o2-mcp" = json!({"enabled": false}))
)
)]
pub async fn clear(
Path(org_id): Path<String>,
Headers(user_email): Headers<UserEmail>,
Query(params): Query<QueryHistoryClearParams>,
) -> Response {
let sources = match parse_sources(params.source.as_deref()) {
Ok(sources) => sources,
Err(e) => return MetaHttpResponse::bad_request(e),
};
let include_starred = params.include_starred.unwrap_or(false);
match query_history::clear(&org_id, &user_email.user_id, &sources, include_starred).await {
Ok(deleted) => MetaHttpResponse::json(QueryHistoryClearResponse { deleted }),
Err(e) => MetaHttpResponse::internal_error(e),
}
}
/// StarQueryHistory
#[utoipa::path(
@@ -186,7 +302,7 @@ pub async fn list(
tag = "Query History",
operation_id = "StarQueryHistory",
summary = "Star or unstar a query history entry",
description = "Starred entries are kept until deleted. A user can star at most 200 entries per organization.",
description = "Starred entries are kept until deleted. A user can star at most 200 entries per organization, shared across every source (logs, traces, metrics, dbm, profiles).",
security(
("Authorization"= [])
),
@@ -213,10 +329,10 @@ pub async fn star(
Json(req): Json<QueryHistoryStarRequest>,
) -> Response {
match query_history::set_starred(&org_id, &user_email.user_id, &id, req.starred).await {
Ok(SetStarred::Updated(entry)) => MetaHttpResponse::json(QueryHistoryEntry::from(entry)),
Ok(SetStarred::Updated(entry)) => MetaHttpResponse::json(QueryHistoryEntry::from(*entry)),
Ok(SetStarred::NotFound) => MetaHttpResponse::not_found("query history entry not found"),
Ok(SetStarred::StarredCapReached) => MetaHttpResponse::bad_request(format!(
"at most {MAX_STARRED_PER_USER} query history entries can be starred; unstar one first"
"at most {MAX_STARRED_PER_USER} query history entries can be starred across all sources; unstar one first"
)),
Err(e) => MetaHttpResponse::internal_error(e),
}
@@ -260,6 +376,81 @@ pub async fn delete(
}
}
fn list_limit(limit: Option<u64>) -> u64 {
limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT)
}
/// The databases bind OFFSET as a signed 64-bit integer.
fn list_offset(offset: Option<u64>) -> Result<u64, String> {
let offset = offset.unwrap_or(0);
if offset > i64::MAX as u64 {
return Err(format!("offset exceeds {}", i64::MAX));
}
Ok(offset)
}
/// Splits a comma list of sources; empty means all, an unknown name is an error.
fn parse_sources(sources: Option<&str>) -> Result<Vec<String>, String> {
let mut parsed = Vec::new();
for source in sources.unwrap_or("").split(',').map(str::trim) {
if source.is_empty() {
continue;
}
if !SOURCES.contains(&source) {
return Err(format!("unknown source: {source}"));
}
parsed.push(source.to_string());
}
Ok(parsed)
}
fn parse_status(status: Option<&str>) -> Result<Option<String>, String> {
match status.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(None),
Some(s) if s == STATUS_OK || s == STATUS_ERROR => Ok(Some(s.to_string())),
Some(s) => Err(format!("unknown status: {s}")),
}
}
/// The source and status a record stores, or why the request is refused.
fn validate_record(req: &QueryHistoryRequest) -> Result<(String, String), String> {
let source = req
.source
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(DEFAULT_SOURCE);
if !SOURCES.contains(&source) {
return Err(format!("unknown source: {source}"));
}
let status = parse_status(req.status.as_deref())?.unwrap_or_else(|| STATUS_OK.to_string());
if req.query.trim().is_empty() {
return Err("query is empty".to_string());
}
if req.query.len() > MAX_QUERY_BYTES {
return Err("query exceeds 16 KB".to_string());
}
if req.context.to_string().len() > MAX_CONTEXT_BYTES {
return Err("context exceeds 64 KB".to_string());
}
if req.streams.len() > MAX_STREAMS {
return Err(format!("at most {MAX_STREAMS} streams"));
}
let invalid: Vec<String> = req
.streams
.iter()
.filter(|s| s.is_empty() || s.contains(','))
.map(|s| format!("{s:?}"))
.collect();
if !invalid.is_empty() {
return Err(format!(
"stream names must be non-empty and contain no comma: {}",
invalid.join(", ")
));
}
Ok((source.to_string(), status))
}
async fn record_entry(
enabled: bool,
org_id: &str,
@@ -269,24 +460,22 @@ async fn record_entry(
if !enabled {
return StatusCode::NO_CONTENT.into_response();
}
if req.query.trim().is_empty() {
return MetaHttpResponse::bad_request("query is empty");
}
if req.query.len() > MAX_QUERY_BYTES {
return MetaHttpResponse::bad_request("query exceeds 16 KB");
}
if req.context.to_string().len() > MAX_CONTEXT_BYTES {
return MetaHttpResponse::bad_request("context exceeds 64 KB");
}
match query_history::record(
let (source, status) = match validate_record(&req) {
Ok(valid) => valid,
Err(e) => return MetaHttpResponse::bad_request(e),
};
let run = NewRun {
org_id,
user_email,
&req.query,
req.context,
config::utils::time::now_micros(),
)
.await
{
source: &source,
query: &req.query,
query_key: req.query_key.as_deref(),
streams: &req.streams,
status: &status,
context: req.context,
now_us: config::utils::time::now_micros(),
};
match query_history::record(run).await {
Ok(entry) => MetaHttpResponse::json(QueryHistoryEntry::from(entry)),
Err(e) => MetaHttpResponse::internal_error(e),
}
@@ -298,45 +487,81 @@ async fn list_entries(
user_email: &str,
params: QueryHistoryListParams,
) -> Response {
let count_only = params.count_only.unwrap_or(false);
if !enabled {
return MetaHttpResponse::json(Vec::<QueryHistoryEntry>::new());
return MetaHttpResponse::json(if count_only {
QueryHistoryListResponse::Count(QueryHistoryCountResponse { count: 0 })
} else {
QueryHistoryListResponse::Entries(Vec::new())
});
}
let (filter, offset) =
match list_filter(&params).and_then(|f| Ok((f, list_offset(params.offset)?))) {
Ok(valid) => valid,
Err(e) => return MetaHttpResponse::bad_request(e),
};
if count_only {
return match query_history::count(org_id, user_email, &filter).await {
Ok(count) => {
MetaHttpResponse::json(QueryHistoryListResponse::Count(QueryHistoryCountResponse {
count,
}))
}
Err(e) => MetaHttpResponse::internal_error(e),
};
}
match query_history::list(
org_id,
user_email,
params.starred,
params.q.as_deref(),
&filter,
list_limit(params.limit),
params.offset.unwrap_or(0),
offset,
)
.await
{
Ok(entries) => MetaHttpResponse::json(
entries
.into_iter()
.map(QueryHistoryEntry::from)
.collect::<Vec<_>>(),
),
Ok(entries) => MetaHttpResponse::json(QueryHistoryListResponse::Entries(
entries.into_iter().map(QueryHistoryEntry::from).collect(),
)),
Err(e) => MetaHttpResponse::internal_error(e),
}
}
fn list_filter(params: &QueryHistoryListParams) -> Result<ListFilter, String> {
// A v1.1.x client sends no source and can only load Metrics entries.
let sources = parse_sources(params.source.as_deref())?;
let sources = if sources.is_empty() {
vec![DEFAULT_SOURCE.to_string()]
} else {
sources
};
Ok(ListFilter {
sources,
status: parse_status(params.status.as_deref())?,
stream: params.stream.clone(),
q: params.q.clone(),
starred: params.starred,
ran_from: params.ran_from,
ran_to: params.ran_to,
})
}
#[cfg(test)]
mod tests {
use infra::table::entity::query_history::Entity;
use sea_orm::{ConnectionTrait, Schema};
use serde_json::{Value, json};
use super::*;
const ALL: &str = "source=logs,traces,metrics,dbm,profiles";
async fn setup() {
infra::db::create_table().await.unwrap();
let conn = infra::db::get_orm_client_rw().await;
let backend = conn.get_database_backend();
let mut stmt = Schema::new(backend).create_table_from_entity(Entity);
conn.execute(backend.build(stmt.if_not_exists()))
.await
.unwrap();
static TABLE: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
TABLE
.get_or_init(|| async {
infra::db::create_table().await.unwrap();
let conn = infra::db::get_orm_client_rw().await;
query_history::create_table_for_tests(conn).await.unwrap();
})
.await;
}
fn user() -> String {
@@ -349,6 +574,17 @@ mod tests {
})
}
fn request(query: &str, context: Value) -> QueryHistoryRequest {
QueryHistoryRequest {
source: None,
query: query.to_string(),
query_key: None,
streams: Vec::new(),
status: None,
context,
}
}
async fn parts(resp: Response) -> (StatusCode, Value) {
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
@@ -360,23 +596,26 @@ mod tests {
)
}
async fn post(org: &str, user: &str, query: &str, context: Value) -> (StatusCode, Value) {
let req = QueryHistoryRequest {
query: query.to_string(),
context,
};
async fn post_req(org: &str, user: &str, req: QueryHistoryRequest) -> (StatusCode, Value) {
parts(record(Path(org.to_string()), headers(user), Json(req)).await).await
}
async fn post(org: &str, user: &str, query: &str, context: Value) -> (StatusCode, Value) {
post_req(org, user, request(query, context)).await
}
async fn get(org: &str, user: &str, params: &str) -> Vec<Value> {
let params: QueryHistoryListParams = parse_params(params);
let (status, body) =
parts(list(Path(org.to_string()), headers(user), Query(params)).await).await;
assert_eq!(status, StatusCode::OK);
let (status, body) = get_raw(org, user, params).await;
assert_eq!(status, StatusCode::OK, "{body}");
body.as_array().unwrap().clone()
}
fn parse_params(params: &str) -> QueryHistoryListParams {
async fn get_raw(org: &str, user: &str, params: &str) -> (StatusCode, Value) {
let params: QueryHistoryListParams = parse_params(params);
parts(list(Path(org.to_string()), headers(user), Query(params)).await).await
}
fn parse_params<T: serde::de::DeserializeOwned>(params: &str) -> T {
let uri: axum::http::Uri = format!("/?{params}").parse().unwrap();
Query::try_from_uri(&uri).unwrap().0
}
@@ -392,6 +631,17 @@ mod tests {
parts(delete(path, headers(user)).await).await.0
}
async fn clear_all(org: &str, user: &str, params: &str) -> (StatusCode, Value) {
let params: QueryHistoryClearParams = parse_params(params);
parts(clear(Path(org.to_string()), headers(user), Query(params)).await).await
}
fn queries(rows: Vec<Value>) -> Vec<String> {
rows.iter()
.map(|e| e["query"].as_str().unwrap().to_string())
.collect()
}
#[tokio::test]
async fn test_another_users_entry_is_not_found() {
setup().await;
@@ -438,73 +688,317 @@ mod tests {
}
#[tokio::test]
async fn test_rerun_updates_the_latest_entry_context() {
async fn test_an_old_client_without_source_or_key_merges_by_query_into_metrics() {
setup().await;
let a = user();
post("default", &a, "up", json!({"chart": "line"})).await;
post("default", &a, "up", json!({"chart": "bar"})).await;
let (_, entry) = post("default", &a, "up", json!({"chart": "bar"})).await;
assert_eq!(entry["source"], "metrics");
assert_eq!(entry["status"], "ok");
assert_eq!(entry["run_count"], 2);
assert_eq!(entry["streams"], json!([]));
assert_eq!(entry["query_key"].as_str().unwrap().len(), 64);
let listed = get("default", &a, "").await;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0]["context"]["chart"], "bar");
assert!(listed[0]["updated_at"].as_i64() >= listed[0]["created_at"].as_i64());
}
#[tokio::test]
async fn test_oversized_query_or_context_is_rejected() {
async fn test_a_v1_1_row_without_updated_at_answers_its_created_at() {
use sea_orm::ConnectionTrait;
setup().await;
let a = user();
let long_query = "x".repeat(MAX_QUERY_BYTES + 1);
let id = config::ider::uuid();
infra::db::get_orm_client_rw()
.await
.execute_unprepared(&format!(
"INSERT INTO query_history (id, org_id, user_email, query, context, created_at, \
updated_at, source) VALUES ('{id}', 'default', '{a}', 'up', '{{}}', 500, 0, \
'metrics')"
))
.await
.unwrap();
let listed = get("default", &a, "").await;
assert_eq!(
post("default", &a, &long_query, json!({})).await.0,
StatusCode::BAD_REQUEST
(
listed[0]["created_at"].as_i64(),
listed[0]["updated_at"].as_i64()
),
(Some(500), Some(500))
);
let big = json!({"d": "x".repeat(MAX_CONTEXT_BYTES)});
assert_eq!(
post("default", &a, "up", big).await.0,
StatusCode::BAD_REQUEST
);
let max_query = "x".repeat(MAX_QUERY_BYTES);
assert_eq!(
post("default", &a, &max_query, json!({})).await.0,
StatusCode::OK
);
assert_eq!(get("default", &a, "").await.len(), 1);
let (status, starred) = patch("default", &a, &id, true).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(starred["updated_at"], 500);
assert_eq!(starred["created_at"], 500);
}
#[tokio::test]
async fn test_blank_query_is_rejected() {
async fn test_repeats_of_one_search_stay_one_entry() {
setup().await;
let a = user();
for query in ["", " ", "\n\t "] {
for _ in 0..3 {
let req = QueryHistoryRequest {
source: Some("logs".to_string()),
query_key: Some("k8s|sql|up|".to_string()),
streams: vec!["k8s".to_string()],
..request("up", json!({}))
};
assert_eq!(post_req("default", &a, req).await.0, StatusCode::OK);
}
let rows = get("default", &a, "source=logs").await;
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["run_count"], 3);
assert_eq!(rows[0]["streams"], json!(["k8s"]));
}
#[tokio::test]
async fn test_query_key_is_a_hash_per_user_and_falls_back_to_the_query() {
setup().await;
let (a, b) = (user(), user());
let keyed = |key: Option<&str>| QueryHistoryRequest {
source: Some("logs".to_string()),
query_key: key.map(str::to_string),
..request("up", json!({}))
};
let (_, first) = post_req("default", &a, keyed(Some("k8s|sql|up|"))).await;
let key = first["query_key"].as_str().unwrap();
assert_eq!(key.len(), 64);
assert!(key.chars().all(|c| c.is_ascii_hexdigit()), "{key}");
assert_ne!(key, "k8s|sql|up|");
let (_, other_user) = post_req("default", &b, keyed(Some("k8s|sql|up|"))).await;
assert_eq!(other_user["query_key"], first["query_key"]);
assert_ne!(other_user["id"], first["id"], "user_email is in the key");
assert_eq!(other_user["run_count"], 1);
let (_, no_key) = post_req("default", &a, keyed(None)).await;
let (_, query_as_key) = post_req("default", &a, keyed(Some("up"))).await;
assert_eq!(query_as_key["id"], no_key["id"], "no key hashes the query");
assert_eq!(query_as_key["run_count"], 2);
assert_eq!(get("default", &a, "source=logs").await.len(), 2);
}
#[test]
fn test_invalid_stream_names_are_listed_in_the_error() {
let req = QueryHistoryRequest {
streams: vec!["ok".to_string(), "a,b".to_string(), String::new()],
..request("up", json!({}))
};
assert_eq!(
validate_record(&req).unwrap_err(),
r#"stream names must be non-empty and contain no comma: "a,b", """#
);
}
#[tokio::test]
async fn test_record_validation() {
setup().await;
let a = user();
let refused = [
QueryHistoryRequest {
source: Some("dashboards".to_string()),
..request("up", json!({}))
},
QueryHistoryRequest {
status: Some("timeout".to_string()),
..request("up", json!({}))
},
QueryHistoryRequest {
streams: (0..=MAX_STREAMS).map(|i| format!("s{i}")).collect(),
..request("up", json!({}))
},
QueryHistoryRequest {
streams: vec!["a,b".to_string()],
..request("up", json!({}))
},
request(&"x".repeat(MAX_QUERY_BYTES + 1), json!({})),
request("up", json!({"d": "x".repeat(MAX_CONTEXT_BYTES)})),
request("", json!({})),
request(" \n\t ", json!({})),
];
for req in refused {
assert_eq!(
post("default", &a, query, json!({})).await.0,
StatusCode::BAD_REQUEST,
"{query:?}"
post_req("default", &a, req).await.0,
StatusCode::BAD_REQUEST
);
}
assert!(get("default", &a, "").await.is_empty());
let accepted = QueryHistoryRequest {
source: Some("traces".to_string()),
status: Some("error".to_string()),
streams: (0..MAX_STREAMS).map(|i| format!("s{i}")).collect(),
..request(&"x".repeat(MAX_QUERY_BYTES), json!({}))
};
let (status, entry) = post_req("default", &a, accepted).await;
assert_eq!(status, StatusCode::OK, "{entry}");
assert_eq!(entry["status"], "error");
assert_eq!(entry["source"], "traces");
}
#[tokio::test]
async fn test_list_applies_filters() {
setup().await;
let a = user();
for query in ["up", "rate(http_total[5m])", "sum(http_total)"] {
post("default", &a, query, json!({})).await;
for (source, query, stream, status) in [
("metrics", "up", None, "ok"),
("logs", "level = 'error'", Some("k8s_logs"), "error"),
("traces", "service = 'cart'", Some("default"), "ok"),
("metrics", "sum(http_total)", None, "ok"),
] {
let req = QueryHistoryRequest {
source: Some(source.to_string()),
streams: stream.map(|s| vec![s.to_string()]).unwrap_or_default(),
status: Some(status.to_string()),
..request(query, json!({}))
};
post_req("default", &a, req).await;
}
let queries = |rows: Vec<Value>| {
rows.iter()
.map(|e| e["query"].as_str().unwrap().to_string())
.collect::<Vec<_>>()
};
assert_eq!(
queries(get("default", &a, "q=http_total&limit=1").await),
queries(get("default", &a, "source=logs,traces").await),
vec!["service = 'cart'", "level = 'error'"]
);
assert_eq!(
queries(get("default", &a, &format!("{ALL}&status=error")).await),
vec!["level = 'error'"]
);
assert_eq!(
queries(get("default", &a, &format!("{ALL}&stream=k8s_logs")).await),
vec!["level = 'error'"]
);
assert_eq!(
queries(get("default", &a, &format!("{ALL}&q=K8S")).await),
vec!["level = 'error'"]
);
assert_eq!(
queries(get("default", &a, "").await),
vec!["sum(http_total)", "up"],
"a client that sends no source is a v1.1.x Metrics page"
);
assert_eq!(
queries(get("default", &a, "source=metrics&limit=1").await),
vec!["sum(http_total)"]
);
assert_eq!(
queries(get("default", &a, "q=http_total&offset=1").await),
vec!["rate(http_total[5m])"]
queries(get("default", &a, "source=metrics&offset=1").await),
vec!["up"]
);
assert!(
get("default", &a, &format!("{ALL}&ran_from=1&ran_to=2"))
.await
.is_empty()
);
assert_eq!(
get("default", &a, &format!("{ALL}&ran_from=1")).await.len(),
4
);
assert!(
get("default", &a, &format!("{ALL}&starred=true"))
.await
.is_empty()
);
assert_eq!(
get_raw("default", &a, "source=dashboards").await.0,
StatusCode::BAD_REQUEST
);
assert_eq!(
get_raw("default", &a, "status=partial").await.0,
StatusCode::BAD_REQUEST
);
}
#[tokio::test]
async fn test_count_only_answers_the_count_for_the_filters() {
setup().await;
let a = user();
for (source, query) in [
("logs", "a"),
("logs", "b"),
("traces", "c"),
("metrics", "d"),
] {
let req = QueryHistoryRequest {
source: Some(source.to_string()),
..request(query, json!({}))
};
post_req("default", &a, req).await;
}
let starred = get("default", &a, "source=logs").await[0]["id"]
.as_str()
.unwrap()
.to_string();
patch("default", &a, &starred, true).await;
let count = |params: &str| {
let a = a.clone();
let params = format!("count_only=true&{params}");
async move { get_raw("default", &a, &params).await }
};
assert_eq!(
count("source=logs").await,
(StatusCode::OK, json!({"count": 2}))
);
assert_eq!(
count("source=logs&starred=false").await.1,
json!({"count": 1})
);
assert_eq!(
count(&format!("{ALL}&starred=true")).await.1,
json!({"count": 1})
);
assert_eq!(count(ALL).await.1, json!({"count": 4}));
assert_eq!(count("source=logs&limit=1").await.1, json!({"count": 2}));
assert_eq!(count("source=nope").await.0, StatusCode::BAD_REQUEST);
let disabled = list_entries(
false,
"default",
&a,
parse_params("count_only=true&source=logs"),
)
.await;
assert_eq!(parts(disabled).await, (StatusCode::OK, json!({"count": 0})));
}
#[tokio::test]
async fn test_clear_my_history() {
setup().await;
let (a, b) = (user(), user());
for (source, query) in [
("logs", "a"),
("logs", "b"),
("traces", "c"),
("metrics", "d"),
] {
let req = QueryHistoryRequest {
source: Some(source.to_string()),
..request(query, json!({}))
};
post_req("default", &a, req).await;
}
post("default", &b, "up", json!({})).await;
let starred = get("default", &a, "source=logs").await[0]["id"]
.as_str()
.unwrap()
.to_string();
patch("default", &a, &starred, true).await;
let (status, body) = clear_all("default", &a, "source=logs").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!({"deleted": 1}));
assert_eq!(clear_all("default", &a, "").await.1, json!({"deleted": 2}));
assert_eq!(queries(get("default", &a, ALL).await), vec!["b"]);
assert_eq!(
clear_all("default", &a, "include_starred=true").await.1,
json!({"deleted": 1})
);
assert!(get("default", &a, ALL).await.is_empty());
assert_eq!(get("default", &b, ALL).await.len(), 1);
assert_eq!(
clear_all("default", &a, "source=nope").await.0,
StatusCode::BAD_REQUEST
);
assert!(get("default", &a, "starred=true").await.is_empty());
}
#[tokio::test]
@@ -512,10 +1006,7 @@ mod tests {
setup().await;
let a = user();
post("default", &a, "up", json!({})).await;
let req = QueryHistoryRequest {
query: "rate(x[5m])".to_string(),
context: json!({}),
};
let req = request("rate(x[5m])", json!({}));
let (status, body) = parts(record_entry(false, "default", &a, req).await).await;
assert_eq!(status, StatusCode::NO_CONTENT);
assert_eq!(body, Value::Null, "204 carries no body");
@@ -535,4 +1026,37 @@ mod tests {
assert_eq!(list_limit(Some(500)), 200);
assert_eq!(list_limit(Some(0)), 1);
}
#[tokio::test]
async fn test_an_offset_past_i64_max_is_a_bad_request() {
setup().await;
let a = user();
post("default", &a, "up", json!({})).await;
let max = i64::MAX as u64;
let (status, _) = get_raw("default", &a, &format!("offset={}", max + 1)).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
let (status, _) = get_raw(
"default",
&a,
&format!("offset={}&count_only=true", u64::MAX),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
get("default", &a, &format!("offset={max}"))
.await
.is_empty()
);
assert_eq!(list_offset(None), Ok(0));
}
#[test]
fn test_sources_parse_as_a_comma_list_of_known_pages() {
assert_eq!(parse_sources(None), Ok(vec![]));
assert_eq!(
parse_sources(Some("logs, traces,")),
Ok(vec!["logs".to_string(), "traces".to_string()])
);
assert!(parse_sources(Some("logs,alerts")).is_err());
}
}
@@ -249,6 +249,10 @@ struct ConfigResponse<'a> {
synthetics_recorder_extension_url: String,
/// The only DBM flag (`ZO_DB_MONITORING_ENABLED`): one OSS switch for every signal.
database_monitoring_enabled: bool,
/// Off hides recent searches in the UI; the server then records nothing.
query_history_enabled: bool,
/// Days unstarred recent searches are kept; 0 or less keeps them until the per-page cap.
query_history_retention_days: i64,
enable_cross_linking: bool,
show_fts_field_values: bool,
search_inspector_enabled: bool,
@@ -668,6 +672,8 @@ pub async fn zo_config(
synthetics_browser_max_steps,
synthetics_recorder_extension_url: synthetics_recorder_extension_url.to_string(),
database_monitoring_enabled: cfg.db_monitoring.enabled,
query_history_enabled: cfg.limit.query_history_enabled,
query_history_retention_days: cfg.limit.query_history_retention_days,
enable_cross_linking: cfg.common.enable_cross_linking,
show_fts_field_values: cfg.common.show_fts_field_values,
search_inspector_enabled,
@@ -2563,6 +2569,20 @@ mod tests {
assert_eq!(json["public_dashboards_enabled"], true);
}
#[tokio::test]
async fn test_zo_config_serves_query_history_settings() {
let cfg = get_config();
let json = zo_config_json().await;
assert_eq!(
json["query_history_enabled"],
cfg.limit.query_history_enabled
);
assert_eq!(
json["query_history_retention_days"],
cfg.limit.query_history_retention_days
);
}
#[tokio::test]
async fn test_zo_config_reports_slack_per_org() {
let reason = if cfg!(feature = "enterprise") {
+3 -2
View File
@@ -126,7 +126,8 @@ pub type RwBTreeMap<K, V> = tokio::sync::RwLock<BTreeMap<K, V>>;
// 126: add held_action to slack_link_requests.
// 127: add group_label_set to alert_states.
// 128: widen alert_dedup_state.fingerprint to varchar(256).
pub const DB_SCHEMA_VERSION: u64 = 128;
// 129: add source, query_key, streams, run_count, updated_at and status to query_history.
pub const DB_SCHEMA_VERSION: u64 = 129;
pub const DB_SCHEMA_KEY: &str = "/db_schema_version/";
// global version variables
@@ -2935,7 +2936,7 @@ pub struct Limit {
#[env_config(
name = "ZO_QUERY_HISTORY_RETENTION_DAYS",
default = 14,
help = "How long unstarred query history entries are kept, in days. Starred entries are kept until deleted. 0 or less keeps every entry forever; it does not stop collection, ZO_QUERY_HISTORY_ENABLED=false does."
help = "How long unstarred query history entries are kept, in days. Starred entries are kept until deleted. 0 or less removes the age limit; each user still keeps at most 500 unstarred entries per source (logs, traces, metrics, dbm, profiles), the oldest of that source pruned first. It does not stop collection, ZO_QUERY_HISTORY_ENABLED=false does."
)]
pub query_history_retention_days: i64,
#[env_config(name = "ZO_ALERT_SCHEDULE_TIMEOUT", default = 90)] // seconds
+226 -18
View File
@@ -21,6 +21,17 @@ use axum::response::Response as HttpResponse;
use common::meta::http::HttpResponse as MetaHttpResponse;
use serde::Deserialize;
#[cfg(feature = "enterprise")]
const EDGE_HISTORY_WINDOW_MICROS: i64 = 24 * 60 * 60 * 1_000_000;
// Allows clock skew while keeping every bound far from i64 overflow in the step alignment.
#[cfg(any(feature = "enterprise", test))]
const MAX_FUTURE_MICROS: i64 = 24 * 60 * 60 * 1_000_000;
// A stream's own retention can outlast the global one, so the cap never drops below the default.
#[cfg(any(feature = "enterprise", test))]
const MIN_MAX_WINDOW_DAYS: i64 = 3650;
#[cfg(any(feature = "enterprise", test))]
const DAY_MICROS: i64 = 24 * 60 * 60 * 1_000_000;
/// Query parameters for service graph API
#[derive(Debug, Deserialize)]
pub struct ServiceGraphQuery {
@@ -53,6 +64,7 @@ pub struct ServiceGraphQuery {
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
(status = 400, description = "Invalid time window (negative start_time, end_time not after start_time, end_time more than a day ahead, or longer than the data retention, at least 3650 days) or a stream name the metrics query cannot quote", content_type = "application/json", body = String),
(status = 403, description = "Forbidden - Enterprise feature", content_type = "application/json", body = String),
(status = 500, description = "Internal Server Error", content_type = "application/json", body = ()),
),
@@ -65,15 +77,16 @@ pub async fn get_current_topology(
axum::extract::Path(org_id): axum::extract::Path<String>,
axum::extract::Query(query): axum::extract::Query<ServiceGraphQuery>,
) -> HttpResponse {
// Resolve the current window boundaries
let (start_time, end_time) =
if let (Some(start), Some(end)) = (query.start_time, query.end_time) {
(start, end)
} else {
let now = chrono::Utc::now().timestamp_micros();
let window_micros = super::DEFAULT_QUERY_WINDOW_MINUTES * 60 * 1_000_000;
(now - window_micros, now)
};
let (start_time, end_time) = match resolve_window(
query.start_time,
query.end_time,
chrono::Utc::now().timestamp_micros(),
super::DEFAULT_QUERY_WINDOW_MINUTES * 60 * 1_000_000,
max_window_days(config::get_config().compact.data_retention_days),
) {
Ok(window) => window,
Err(e) => return MetaHttpResponse::bad_request(e),
};
let filter = v4_read_filter(&query);
if let Err(e) = filter.validate() {
@@ -152,7 +165,7 @@ pub struct EdgeHistoryQuery {
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
(status = 400, description = "A service or stream name the metrics query cannot quote", content_type = "application/json", body = String),
(status = 400, description = "Invalid time window (negative start_time, end_time not after start_time, end_time more than a day ahead, or longer than the data retention, at least 3650 days), or a service or stream name the metrics query cannot quote", content_type = "application/json", body = String),
(status = 403, description = "Forbidden - Enterprise feature"),
(status = 500, description = "Internal Server Error", content_type = "application/json", body = ()),
),
@@ -166,13 +179,16 @@ pub async fn get_edge_history(
use super::v4::history::{EdgeFilter, fetch_edge_history};
let (start_time, end_time) =
if let (Some(start), Some(end)) = (query.start_time, query.end_time) {
(start, end)
} else {
let now = chrono::Utc::now().timestamp_micros();
(now - 24 * 60 * 60 * 1_000_000, now)
};
let (start_time, end_time) = match resolve_window(
query.start_time,
query.end_time,
chrono::Utc::now().timestamp_micros(),
EDGE_HISTORY_WINDOW_MICROS,
max_window_days(config::get_config().compact.data_retention_days),
) {
Ok(window) => window,
Err(e) => return MetaHttpResponse::bad_request(e),
};
let non_empty = |v: Option<String>| v.filter(|s| !s.is_empty());
let filter = EdgeFilter {
client: non_empty(query.client_service),
@@ -228,10 +244,137 @@ pub async fn get_current_topology(
MetaHttpResponse::forbidden("Not Supported")
}
#[cfg(all(test, feature = "enterprise"))]
/// The longest window a request may ask for: the global data retention, at least 3650 days.
#[cfg(any(feature = "enterprise", test))]
fn max_window_days(retention_days: i64) -> i64 {
retention_days.max(MIN_MAX_WINDOW_DAYS)
}
#[cfg(any(feature = "enterprise", test))]
fn resolve_window(
start: Option<i64>,
end: Option<i64>,
now: i64,
default_span_micros: i64,
max_days: i64,
) -> Result<(i64, i64), String> {
let (Some(start), Some(end)) = (start, end) else {
return Ok((now - default_span_micros, now));
};
if start < 0 {
return Err("start_time must not be negative".to_string());
}
if end <= start {
return Err("end_time must be after start_time".to_string());
}
if end > now.saturating_add(MAX_FUTURE_MICROS) {
return Err("end_time must be at most one day after the current time".to_string());
}
if end - start > max_days.saturating_mul(DAY_MICROS) {
return Err(format!(
"the window from start_time to end_time must not exceed {max_days} days"
));
}
Ok((start, end))
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_800_000_000_000_000;
const SPAN: i64 = 3_600_000_000;
const MAX_DAYS: i64 = 3650;
#[test]
fn test_resolve_window_uses_an_explicit_window() {
assert_eq!(
resolve_window(Some(1_000), Some(2_000), NOW, SPAN, MAX_DAYS),
Ok((1_000, 2_000))
);
assert_eq!(
resolve_window(Some(0), Some(1), NOW, SPAN, MAX_DAYS),
Ok((0, 1))
);
}
#[test]
fn test_resolve_window_rejects_a_negative_start() {
assert!(resolve_window(Some(-1), Some(2_000), NOW, SPAN, MAX_DAYS).is_err());
assert!(resolve_window(Some(i64::MIN), Some(i64::MAX), NOW, SPAN, MAX_DAYS).is_err());
}
#[test]
fn test_resolve_window_rejects_an_end_not_after_the_start() {
assert!(resolve_window(Some(2_189_567), Some(-1_358), NOW, SPAN, MAX_DAYS).is_err());
assert!(resolve_window(Some(2_000), Some(2_000), NOW, SPAN, MAX_DAYS).is_err());
assert!(resolve_window(Some(2_000), Some(1_999), NOW, SPAN, MAX_DAYS).is_err());
}
#[test]
fn test_resolve_window_rejects_an_end_beyond_tomorrow_and_near_i64_max() {
assert_eq!(
resolve_window(
Some(NOW),
Some(NOW + MAX_FUTURE_MICROS),
NOW,
SPAN,
MAX_DAYS
),
Ok((NOW, NOW + MAX_FUTURE_MICROS))
);
assert!(
resolve_window(
Some(NOW),
Some(NOW + MAX_FUTURE_MICROS + 1),
NOW,
SPAN,
MAX_DAYS
)
.is_err()
);
assert!(resolve_window(Some(i64::MAX - 1), Some(i64::MAX), NOW, SPAN, MAX_DAYS).is_err());
assert!(resolve_window(Some(0), Some(i64::MAX), NOW, SPAN, MAX_DAYS).is_err());
assert!(resolve_window(Some(0), Some(1), i64::MAX, SPAN, MAX_DAYS).is_ok());
}
#[test]
fn test_resolve_window_caps_the_span_at_the_given_days() {
let max_span = MAX_DAYS * DAY_MICROS;
assert_eq!(
resolve_window(Some(NOW - max_span), Some(NOW), NOW, SPAN, MAX_DAYS),
Ok((NOW - max_span, NOW))
);
let err =
resolve_window(Some(NOW - max_span - 1), Some(NOW), NOW, SPAN, MAX_DAYS).unwrap_err();
assert!(err.contains("3650 days"), "{err}");
let longer = 7300 * DAY_MICROS;
assert!(resolve_window(Some(NOW - longer), Some(NOW), NOW, SPAN, 7300).is_ok());
assert!(resolve_window(Some(0), Some(NOW), NOW, SPAN, i64::MAX).is_ok());
}
#[test]
fn test_max_window_days_follows_a_longer_retention() {
assert_eq!(max_window_days(7300), 7300);
assert_eq!(max_window_days(3650), 3650);
assert_eq!(
max_window_days(30),
3650,
"a stream may keep data longer than the default"
);
assert_eq!(max_window_days(0), 3650, "0 means unbounded retention");
assert_eq!(max_window_days(-1), 3650);
}
#[test]
fn test_resolve_window_defaults_when_a_bound_is_missing() {
let default = Ok((NOW - SPAN, NOW));
assert_eq!(resolve_window(None, None, NOW, SPAN, MAX_DAYS), default);
assert_eq!(resolve_window(Some(-5), None, NOW, SPAN, MAX_DAYS), default);
assert_eq!(resolve_window(None, Some(-5), NOW, SPAN, MAX_DAYS), default);
}
#[cfg(feature = "enterprise")]
#[test]
fn test_v4_read_filter_maps_stream_and_env_only() {
let query = |stream: Option<&str>, env: Option<&str>| ServiceGraphQuery {
@@ -253,4 +396,69 @@ mod tests {
assert_eq!(f.trace_stream.as_deref(), Some("default"));
assert_eq!(f.agent_env.as_deref(), Some("prod"));
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn test_handlers_reject_an_invalid_window_with_400() {
use axum::extract::{Path, Query};
let topology = get_current_topology(
Path("default".to_string()),
Query(ServiceGraphQuery {
start_time: Some(2_189_567),
end_time: Some(-1_358),
filter: None,
stream_name: None,
agent_id: None,
agent_name: None,
agent_env: None,
}),
)
.await;
assert_eq!(topology.status(), axum::http::StatusCode::BAD_REQUEST);
let history = get_edge_history(
Path("default".to_string()),
Query(EdgeHistoryQuery {
client_service: None,
server_service: None,
start_time: Some(-1),
end_time: Some(10),
stream_name: None,
}),
)
.await;
assert_eq!(history.status(), axum::http::StatusCode::BAD_REQUEST);
let near_max = get_edge_history(
Path("default".to_string()),
Query(EdgeHistoryQuery {
client_service: None,
server_service: None,
start_time: Some(i64::MAX - 1),
end_time: Some(i64::MAX),
stream_name: None,
}),
)
.await;
assert_eq!(near_max.status(), axum::http::StatusCode::BAD_REQUEST);
let near_max_topology = get_current_topology(
Path("default".to_string()),
Query(ServiceGraphQuery {
start_time: Some(i64::MAX - 1),
end_time: Some(i64::MAX),
filter: None,
stream_name: None,
agent_id: None,
agent_name: None,
agent_env: None,
}),
)
.await;
assert_eq!(
near_max_topology.status(),
axum::http::StatusCode::BAD_REQUEST
);
}
}
@@ -90,7 +90,7 @@ pub async fn fetch_edge_history(
/// Coarser than a minute only when the range would exceed `MAX_POINTS`; always whole minutes.
fn step_micros(start: i64, end: i64) -> i64 {
let span_secs = ((end - start) / 1_000_000).max(1) as u64;
let span_secs = (end.saturating_sub(start) / 1_000_000).max(1) as u64;
let minutes = span_secs
.div_ceil(MAX_POINTS as u64)
.div_ceil(MIN_STEP_SECS as u64);
@@ -99,7 +99,7 @@ fn step_micros(start: i64, end: i64) -> i64 {
/// Evaluation times sit on step boundaries, where the rollup's samples are, inside the range.
fn eval_bounds(start: i64, end: i64, step: i64) -> Option<(i64, i64)> {
let first = align_down(start + step - 1, step) + step;
let first = align_down(start.checked_add(step - 1)?, step).checked_add(step)?;
let last = align_down(end, step);
(first <= last).then_some((first, last))
}
@@ -217,6 +217,14 @@ mod tests {
assert_eq!(eval_bounds(0, MIN - 1, MIN), None);
}
#[test]
fn test_step_and_bounds_survive_windows_near_i64_max() {
let step = step_micros(i64::MAX - 1, i64::MAX);
assert_eq!(step, MIN);
assert_eq!(eval_bounds(i64::MAX - 1, i64::MAX, step), None);
assert!(step_micros(i64::MIN, i64::MAX) > 0);
}
#[test]
fn test_plan_queries() {
let plan = plan("server=\"b\"", MIN);
+1 -1
View File
@@ -257,7 +257,7 @@ where
Fut: Future<Output = Result<Vec<InstantValue>, anyhow::Error>>,
{
// Range selectors are left-open; +1ms keeps the `start` sample, not a baseline 1ms before it.
let range_ms = ((end - start) / 1_000).max(1_000) + 1;
let range_ms = (end.saturating_sub(start) / 1_000).max(1_000) + 1;
let plan =
|v4_only| plan_queries(filter, v4_only, range_ms, start, end).map_err(anyhow::Error::msg);
let mut results = run_plan(&plan(true)?, &run).await;
+15 -10
View File
@@ -1876,12 +1876,7 @@ mod tests {
}
async fn create_query_history_table() {
use sea_orm::{ConnectionTrait, Schema};
let conn = get_orm_client_rw().await;
let backend = conn.get_database_backend();
let mut stmt = Schema::new(backend)
.create_table_from_entity(infra_table::entity::query_history::Entity);
conn.execute(backend.build(stmt.if_not_exists()))
infra_table::query_history::create_table_for_tests(get_orm_client_rw().await)
.await
.unwrap();
}
@@ -1906,14 +1901,24 @@ mod tests {
};
create_new_user(user).await.unwrap();
for org in ["dummy", "other"] {
infra_table::query_history::record(org, email, "up", serde_json::json!({}), 1)
.await
.unwrap();
infra_table::query_history::record(infra_table::query_history::NewRun {
org_id: org,
user_email: email,
source: "logs",
query: "up",
query_key: None,
streams: &[],
status: "ok",
context: serde_json::json!({}),
now_us: 1,
})
.await
.unwrap();
}
}
async fn history_count(org: &str, email: &str) -> usize {
infra_table::query_history::list(org, email, None, None, 10, 0)
infra_table::query_history::list(org, email, &Default::default(), 10, 0)
.await
.unwrap()
.len()
@@ -26,7 +26,20 @@ pub struct Model {
pub query: String,
pub context: Json,
pub starred: bool,
/// First run of this row; never changed after insert.
pub created_at: i64,
#[sea_orm(column_type = "Text")]
pub source: String,
/// Hex sha256 of the merge key; NULL on rows written before the key existed.
pub query_key: Option<String>,
/// Comma-wrapped stream list (`,a,b,`), empty when there are none.
#[sea_orm(column_type = "Text")]
pub streams: String,
pub run_count: i64,
/// Last run of this row.
pub updated_at: i64,
#[sea_orm(column_type = "Text")]
pub status: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -0,0 +1,644 @@
// 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/>.
//! Adds source, merge key, streams, run count, last run and status to `query_history`.
use sea_orm_migration::prelude::*;
const TABLE: &str = "query_history";
const OLD_ORG_USER_CREATED_AT_IDX: &str = "query_history_org_user_created_at_idx";
const OLD_STARRED_CREATED_AT_IDX: &str = "query_history_starred_created_at_idx";
const ORG_USER_UPDATED_AT_IDX: &str = "query_history_org_user_updated_at_idx";
const ORG_USER_SOURCE_UPDATED_AT_IDX: &str = "query_history_org_user_source_updated_at_idx";
const STARRED_UPDATED_AT_IDX: &str = "query_history_starred_updated_at_idx";
const ORG_USER_SOURCE_KEY_UIDX: &str = "query_history_org_user_source_key_uidx";
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// NOT NULL DEFAULT keeps v1.1.x nodes inserting during a rolling upgrade.
for mut def in [
ColumnDef::new(QueryHistory::Source)
.text()
.not_null()
.default("metrics")
.to_owned(),
ColumnDef::new(QueryHistory::QueryKey)
.char_len(64)
.null()
.to_owned(),
ColumnDef::new(QueryHistory::Streams)
.text()
.not_null()
.default("")
.to_owned(),
ColumnDef::new(QueryHistory::RunCount)
.big_integer()
.not_null()
.default(1)
.to_owned(),
ColumnDef::new(QueryHistory::UpdatedAt)
.big_integer()
.not_null()
.default(0)
.to_owned(),
ColumnDef::new(QueryHistory::Status)
.text()
.not_null()
.default("ok")
.to_owned(),
] {
add_column(manager, &mut def).await?;
}
// v1.1.x stored each row's last run in created_at; the guard makes a rerun a no-op.
manager
.get_connection()
.execute_unprepared(
"UPDATE query_history SET updated_at = created_at WHERE updated_at < created_at",
)
.await?;
for name in [OLD_ORG_USER_CREATED_AT_IDX, OLD_STARRED_CREATED_AT_IDX] {
drop_index(manager, name).await?;
}
create_index(
manager,
ORG_USER_UPDATED_AT_IDX,
&[
QueryHistory::OrgId,
QueryHistory::UserEmail,
QueryHistory::UpdatedAt,
],
false,
)
.await?;
create_index(
manager,
ORG_USER_SOURCE_UPDATED_AT_IDX,
&[
QueryHistory::OrgId,
QueryHistory::UserEmail,
QueryHistory::Source,
QueryHistory::UpdatedAt,
],
false,
)
.await?;
create_index(
manager,
STARRED_UPDATED_AT_IDX,
&[QueryHistory::Starred, QueryHistory::UpdatedAt],
false,
)
.await?;
// NULL keys on v1.1.x rows are distinct, so they never collide here.
create_index(
manager,
ORG_USER_SOURCE_KEY_UIDX,
&[
QueryHistory::OrgId,
QueryHistory::UserEmail,
QueryHistory::Source,
QueryHistory::QueryKey,
],
true,
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
for name in [
ORG_USER_SOURCE_KEY_UIDX,
STARRED_UPDATED_AT_IDX,
ORG_USER_SOURCE_UPDATED_AT_IDX,
ORG_USER_UPDATED_AT_IDX,
] {
drop_index(manager, name).await?;
}
// A repeated or partial rollback has already dropped updated_at.
if manager
.has_column(TABLE, &QueryHistory::UpdatedAt.to_string())
.await?
{
manager
.get_connection()
.execute_unprepared(
"UPDATE query_history SET created_at = updated_at WHERE updated_at > created_at",
)
.await?;
}
create_index(
manager,
OLD_ORG_USER_CREATED_AT_IDX,
&[
QueryHistory::OrgId,
QueryHistory::UserEmail,
QueryHistory::CreatedAt,
],
false,
)
.await?;
create_index(
manager,
OLD_STARRED_CREATED_AT_IDX,
&[QueryHistory::Starred, QueryHistory::CreatedAt],
false,
)
.await?;
for column in [
QueryHistory::Status,
QueryHistory::UpdatedAt,
QueryHistory::RunCount,
QueryHistory::Streams,
QueryHistory::QueryKey,
QueryHistory::Source,
] {
if manager.has_column(TABLE, &column.to_string()).await? {
manager
.alter_table(
Table::alter()
.table(QueryHistory::Table)
.drop_column(column)
.to_owned(),
)
.await?;
}
}
Ok(())
}
}
#[derive(DeriveIden, Clone, Copy)]
enum QueryHistory {
Table,
OrgId,
UserEmail,
Starred,
CreatedAt,
Source,
QueryKey,
Streams,
RunCount,
UpdatedAt,
Status,
}
async fn add_column(manager: &SchemaManager<'_>, def: &mut ColumnDef) -> Result<(), DbErr> {
if manager.has_column(TABLE, &def.get_column_name()).await? {
return Ok(());
}
manager
.alter_table(
Table::alter()
.table(QueryHistory::Table)
.add_column(def.to_owned())
.to_owned(),
)
.await
}
async fn drop_index(manager: &SchemaManager<'_>, name: &str) -> Result<(), DbErr> {
manager
.drop_index(
Index::drop()
.if_exists()
.name(name)
.table(QueryHistory::Table)
.to_owned(),
)
.await
}
async fn create_index(
manager: &SchemaManager<'_>,
name: &str,
columns: &[QueryHistory],
unique: bool,
) -> Result<(), DbErr> {
let mut index = Index::create();
index.if_not_exists().name(name).table(QueryHistory::Table);
if unique {
index.unique();
}
for column in columns {
index.col(*column);
}
manager.create_index(index.to_owned()).await
}
#[cfg(test)]
mod tests {
use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement};
use sea_orm_migration::{MigrationName, MigrationTrait};
use super::*;
type Row = (
String,
String,
Option<String>,
String,
i64,
i64,
i64,
String,
);
async fn v1_1_table_with_rows() -> DatabaseConnection {
let db = Database::connect("sqlite::memory:").await.unwrap();
seed_v1_1_table(&db).await;
db
}
async fn seed_v1_1_table(db: &DatabaseConnection) {
super::super::m20261004_000001_create_query_history::Migration
.up(&SchemaManager::new(db))
.await
.unwrap();
for (id, starred, created_at) in [("old", false, 100), ("star", true, 200)] {
db.execute_unprepared(&format!(
"INSERT INTO query_history (id, org_id, user_email, query, context, starred, \
created_at) VALUES ('{id}', 'default', 'a@x.com', 'up', '{{\"step\":\"1m\"}}', \
{starred}, {created_at})"
))
.await
.unwrap();
}
}
/// A throwaway schema on the CI Postgres; `None` where none is configured.
async fn postgres_schema() -> Option<(DatabaseConnection, DatabaseConnection, String)> {
let dsn = std::env::var("ZO_META_POSTGRES_DSN").ok()?;
// The SQLite lane sets the variable empty rather than unsetting it.
if dsn.trim().is_empty() {
return None;
}
let schema = format!(
"qh_migration_test_{}",
config::ider::generate().to_ascii_lowercase()
);
let admin = Database::connect(&dsn).await.unwrap();
admin
.execute_unprepared(&format!(r#"CREATE SCHEMA "{schema}""#))
.await
.unwrap();
let mut options = sea_orm::ConnectOptions::new(dsn);
options
.max_connections(1)
.set_schema_search_path(schema.clone());
Some((admin, Database::connect(options).await.unwrap(), schema))
}
async fn rows(db: &DatabaseConnection) -> Vec<Row> {
db.query_all(Statement::from_string(
DbBackend::Sqlite,
"SELECT id, source, query_key, streams, run_count, created_at, updated_at, status \
FROM query_history ORDER BY id",
))
.await
.unwrap()
.iter()
.map(|r| {
(
r.try_get_by_index(0).unwrap(),
r.try_get_by_index(1).unwrap(),
r.try_get_by_index(2).unwrap(),
r.try_get_by_index(3).unwrap(),
r.try_get_by_index(4).unwrap(),
r.try_get_by_index(5).unwrap(),
r.try_get_by_index(6).unwrap(),
r.try_get_by_index(7).unwrap(),
)
})
.collect()
}
async fn index_columns(db: &DatabaseConnection, index: &str) -> Vec<Option<String>> {
db.query_all(Statement::from_string(
DbBackend::Sqlite,
format!("SELECT name FROM pragma_index_info('{index}') ORDER BY seqno"),
))
.await
.unwrap()
.iter()
.map(|r| r.try_get::<Option<String>>("", "name").unwrap())
.collect()
}
#[tokio::test]
async fn test_up_backfills_updated_at_from_created_at_on_a_populated_v1_1_table() {
let db = v1_1_table_with_rows().await;
let manager = SchemaManager::new(&db);
Migration.up(&manager).await.unwrap();
let to_row = |id: &str, created_at: i64, updated_at: i64| -> Row {
(
id.to_string(),
"metrics".to_string(),
None,
String::new(),
1,
created_at,
updated_at,
"ok".to_string(),
)
};
assert_eq!(
rows(&db).await,
vec![to_row("old", 100, 100), to_row("star", 200, 200)],
"every v1.1.x row gets updated_at = created_at"
);
db.execute_unprepared("UPDATE query_history SET updated_at = 900 WHERE id = 'old'")
.await
.unwrap();
Migration.up(&manager).await.expect("idempotent");
assert_eq!(
rows(&db).await,
vec![to_row("old", 100, 900), to_row("star", 200, 200)],
"a rerun leaves a row that ran after the upgrade alone"
);
let listed = crate::table::query_history::list_with(
&db,
"default",
"a@x.com",
&crate::table::query_history::ListFilter {
sources: vec!["metrics".to_string()],
..Default::default()
},
10,
0,
)
.await
.unwrap();
assert_eq!(
listed
.iter()
.map(|r| (r.id.as_str(), r.updated_at))
.collect::<Vec<_>>(),
vec![("old", 900), ("star", 200)]
);
}
#[tokio::test]
async fn test_up_swaps_indexes() {
let db = v1_1_table_with_rows().await;
let manager = SchemaManager::new(&db);
Migration.up(&manager).await.unwrap();
for old in [OLD_ORG_USER_CREATED_AT_IDX, OLD_STARRED_CREATED_AT_IDX] {
assert!(!manager.has_index(TABLE, old).await.unwrap(), "{old}");
}
let names = |cols: &[Option<&str>]| -> Vec<Option<String>> {
cols.iter().map(|c| c.map(str::to_string)).collect()
};
assert_eq!(
index_columns(&db, ORG_USER_UPDATED_AT_IDX).await,
names(&[Some("org_id"), Some("user_email"), Some("updated_at")])
);
assert_eq!(
index_columns(&db, ORG_USER_SOURCE_UPDATED_AT_IDX).await,
names(&[
Some("org_id"),
Some("user_email"),
Some("source"),
Some("updated_at")
])
);
assert_eq!(
index_columns(&db, STARRED_UPDATED_AT_IDX).await,
names(&[Some("starred"), Some("updated_at")])
);
assert_eq!(
index_columns(&db, ORG_USER_SOURCE_KEY_UIDX).await,
names(&[
Some("org_id"),
Some("user_email"),
Some("source"),
Some("query_key")
])
);
}
#[tokio::test]
async fn test_up_leaves_exactly_the_agreed_index_set() {
let db = v1_1_table_with_rows().await;
Migration.up(&SchemaManager::new(&db)).await.unwrap();
let indexes: Vec<(String, bool)> = db
.query_all(Statement::from_string(
DbBackend::Sqlite,
"SELECT name, \"unique\" FROM pragma_index_list('query_history') \
WHERE origin = 'c' ORDER BY name",
))
.await
.unwrap()
.iter()
.map(|r| {
(
r.try_get_by_index(0).unwrap(),
r.try_get_by_index::<i64>(1).unwrap() == 1,
)
})
.collect();
assert_eq!(
indexes,
vec![
(ORG_USER_SOURCE_KEY_UIDX.to_string(), true),
(ORG_USER_SOURCE_UPDATED_AT_IDX.to_string(), false),
(ORG_USER_UPDATED_AT_IDX.to_string(), false),
(STARRED_UPDATED_AT_IDX.to_string(), false),
]
);
}
#[tokio::test]
async fn test_unique_key_allows_null_keys_and_refuses_a_duplicate_key() {
let db = v1_1_table_with_rows().await;
Migration.up(&SchemaManager::new(&db)).await.unwrap();
let insert = |id: &str, key: &str| {
format!(
"INSERT INTO query_history (id, org_id, user_email, query, context, created_at, \
query_key) VALUES ('{id}', 'default', 'a@x.com', 'up', '{{}}', 1, {key})"
)
};
db.execute_unprepared(&insert("legacy", "NULL"))
.await
.expect("a v1.1.x node inserts with no key");
db.execute_unprepared(&insert("k1", "'abc'")).await.unwrap();
assert!(db.execute_unprepared(&insert("k2", "'abc'")).await.is_err());
}
#[tokio::test]
async fn test_down_restores_the_v1_1_table() {
let db = v1_1_table_with_rows().await;
let manager = SchemaManager::new(&db);
Migration.up(&manager).await.unwrap();
Migration.down(&manager).await.unwrap();
for column in [
"source",
"query_key",
"streams",
"run_count",
"updated_at",
"status",
] {
assert!(
!manager.has_column(TABLE, column).await.unwrap(),
"{column}"
);
}
assert!(
manager
.has_index(TABLE, OLD_ORG_USER_CREATED_AT_IDX)
.await
.unwrap()
);
assert!(
manager
.has_index(TABLE, OLD_STARRED_CREATED_AT_IDX)
.await
.unwrap()
);
}
#[tokio::test]
async fn test_down_keeps_the_last_run_of_rows_an_old_node_wrote() {
let db = v1_1_table_with_rows().await;
let manager = SchemaManager::new(&db);
Migration.up(&manager).await.unwrap();
db.execute_unprepared(
"INSERT INTO query_history (id, org_id, user_email, query, context, created_at) \
VALUES ('mid', 'default', 'a@x.com', 'up', '{}', 300)",
)
.await
.unwrap();
db.execute_unprepared("UPDATE query_history SET updated_at = 900 WHERE id = 'old'")
.await
.unwrap();
Migration.down(&manager).await.unwrap();
let created: Vec<(String, i64)> = db
.query_all(Statement::from_string(
DbBackend::Sqlite,
"SELECT id, created_at FROM query_history ORDER BY id",
))
.await
.unwrap()
.iter()
.map(|r| {
(
r.try_get_by_index(0).unwrap(),
r.try_get_by_index(1).unwrap(),
)
})
.collect();
assert_eq!(
created,
vec![
("mid".to_string(), 300),
("old".to_string(), 900),
("star".to_string(), 200)
]
);
}
#[tokio::test]
async fn test_down_runs_twice() {
let db = v1_1_table_with_rows().await;
let manager = SchemaManager::new(&db);
Migration.up(&manager).await.unwrap();
Migration.down(&manager).await.unwrap();
Migration
.down(&manager)
.await
.expect("a second rollback is a no-op");
}
#[tokio::test]
async fn test_postgres_up_and_down_on_a_populated_v1_1_table_when_ci_provides_it() {
let Some((admin, db, schema)) = postgres_schema().await else {
return;
};
seed_v1_1_table(&db).await;
let manager = SchemaManager::new(&db);
Migration.up(&manager).await.unwrap();
Migration.up(&manager).await.expect("idempotent");
for index in [
ORG_USER_UPDATED_AT_IDX,
ORG_USER_SOURCE_UPDATED_AT_IDX,
STARRED_UPDATED_AT_IDX,
ORG_USER_SOURCE_KEY_UIDX,
] {
assert!(manager.has_index(TABLE, index).await.unwrap(), "{index}");
}
let listed = crate::table::query_history::list_with(
&db,
"default",
"a@x.com",
&crate::table::query_history::ListFilter {
sources: vec!["metrics".to_string()],
..Default::default()
},
10,
0,
)
.await
.unwrap();
// Postgres pads the char(27) id.
assert_eq!(
listed
.iter()
.map(|r| (r.id.trim(), r.updated_at))
.collect::<Vec<_>>(),
vec![("star", 200), ("old", 100)]
);
let insert = |id: &str| {
format!(
"INSERT INTO query_history (id, org_id, user_email, query, context, created_at, \
query_key) VALUES ('{id}', 'default', 'a@x.com', 'up', '{{}}', 1, 'abc')"
)
};
db.execute_unprepared(&insert("k1")).await.unwrap();
assert!(db.execute_unprepared(&insert("k2")).await.is_err());
Migration.down(&manager).await.unwrap();
Migration
.down(&manager)
.await
.expect("a second rollback is a no-op");
assert!(!manager.has_column(TABLE, "updated_at").await.unwrap());
assert!(
manager
.has_index(TABLE, OLD_ORG_USER_CREATED_AT_IDX)
.await
.unwrap()
);
db.close().await.unwrap();
admin
.execute_unprepared(&format!(r#"DROP SCHEMA "{schema}" CASCADE"#))
.await
.unwrap();
}
#[test]
fn test_migration_name() {
assert_eq!(Migration.name(), "m20261027_000001_alter_query_history");
}
}
+17
View File
@@ -241,6 +241,7 @@ mod m20261025_000003_add_mute_targets_to_downtimes;
mod m20261025_000004_create_slack_alert_messages;
mod m20261025_000005_add_held_action_to_slack_link_requests;
mod m20261026_000001_backfill_v1_rca_pointer;
mod m20261027_000001_alter_query_history;
mod m20261028_000001_add_group_label_set_to_alert_states;
mod m20261029_000001_widen_alert_dedup_fingerprint;
/// Shared body of the two `folder_id` migrations above; not a migration itself.
@@ -501,6 +502,20 @@ pub(crate) async fn backfill_v1_rca_pointer(db: &sea_orm::DatabaseConnection) ->
.await
}
/// Apply the query_history table and its columns, idempotently, for tests in any crate.
#[cfg(any(test, feature = "test-utils"))]
pub(crate) async fn create_query_history_table(
db: &sea_orm::DatabaseConnection,
) -> Result<(), DbErr> {
let manager = SchemaManager::new(db);
m20261004_000001_create_query_history::Migration
.up(&manager)
.await?;
m20261027_000001_alter_query_history::Migration
.up(&manager)
.await
}
pub struct Migrator;
#[async_trait::async_trait]
@@ -732,6 +747,7 @@ impl MigratorTrait for Migrator {
Box::new(m20261025_000005_add_held_action_to_slack_link_requests::Migration),
Box::new(m20261028_000001_add_group_label_set_to_alert_states::Migration),
Box::new(m20261029_000001_widen_alert_dedup_fingerprint::Migration),
Box::new(m20261027_000001_alter_query_history::Migration),
]
}
}
@@ -825,6 +841,7 @@ mod tests {
),
(127, "m20261028_000001_add_group_label_set_to_alert_states"),
(128, "m20261029_000001_widen_alert_dedup_fingerprint"),
(129, "m20261027_000001_alter_query_history"),
];
// Two branches that each claim the next version merge cleanly, so a reused number must fail
File diff suppressed because it is too large Load Diff
@@ -157,11 +157,12 @@ export class MetricsPage {
this.shareErrorToast = page.locator('[data-test-variant="error"][data-test-message*="Error shortening link"]');
this.toastMessage = page.locator('[data-test="o-toast-message"]');
// Query history drawer (QueryHistoryDrawer.vue)
this.historyButton = page.locator('[data-test="metrics-history-btn"]');
this.historyTable = page.locator('[data-test="metrics-history-table"]');
this.historySearch = page.locator('[data-test="metrics-history-search"] input');
this.historyStarredOnly = page.locator('[data-test="metrics-history-starred-only"]');
this.historyPopover = page.locator('[data-test="recent-searches-popover"]');
this.historyViewAll = this.historyPopover.locator('[data-test="recent-searches-view-all"]');
this.historyDrawer = page.locator('[data-test="recent-searches-drawer"]');
this.historySearch = this.historyDrawer.locator('[data-test="recent-searches-search"] input');
this.historyStarredOnly = this.historyDrawer.locator('[data-test="recent-searches-starred-only"]');
}
/**
@@ -2793,7 +2794,8 @@ export class MetricsPage {
async openQueryHistory() {
await this.historyButton.click();
await this.historyTable.waitFor({ state: 'visible', timeout: 15000 });
await this.historyViewAll.click();
await this.historyDrawer.waitFor({ state: 'visible', timeout: 15000 });
}
/** Resolves once the filtered list answers: a click landing mid-re-render is lost. */
@@ -2805,24 +2807,33 @@ export class MetricsPage {
await listed;
}
/** The history row whose query cell is exactly `query`. */
/** The drawer row whose query text contains `query`. */
historyRow(query) {
return this.historyTable.locator('[data-test^="o2-table-row-"]').filter({
has: this.page.locator('[data-test="o2-table-cell-query"]', { hasText: query }),
return this.historyDrawer.locator('[data-test^="recent-searches-drawer-row-"]').filter({
has: this.page.locator('[data-test="recent-searches-drawer-query"]', { hasText: query }),
});
}
/** A row's star toggle; its data-test carries the entry id, so match the prefix. */
historyStarButton(query) {
return this.historyRow(query).locator('[data-test^="metrics-history-star-"]');
return this.historyRow(query).locator('[data-test^="recent-searches-star-"]');
}
/** Picks `action` (load, delete, copy-link, copy-query) from a row's "More" menu. */
async historyRowAction(query, action) {
const rowTest = await this.historyRow(query).getAttribute('data-test');
const id = rowTest.replace('recent-searches-drawer-row-', '');
await this.historyRow(query).locator(`[data-test="recent-searches-more-${id}"]`).click();
await this.page.locator(`[data-test="recent-searches-${action}-${id}"]`).click();
}
/** The DELETE is sent once the row's Undo toast closes, so callers wait for it. */
async deleteHistoryEntry(query) {
await this.historyRow(query).locator('[data-test^="metrics-history-delete-"]').click();
await this.historyRowAction(query, 'delete');
}
/** Clicking a row's query cell loads the entry into the open editor. */
/** Loads the entry into the editor without running it. */
async loadHistoryEntry(query) {
await this.historyRow(query).locator('[data-test="o2-table-cell-query"]').click();
await this.historyRowAction(query, 'load');
}
}
@@ -43,28 +43,41 @@ test.describe('Metrics editor query history', () => {
await custom.click();
await expect(custom).toHaveAttribute('data-state', 'on');
await runAndRecord(page, pm, first);
await runAndRecord(page, pm, second);
await metrics.openQueryHistory();
await metrics.searchQueryHistory(String(marker));
await expect(metrics.historyRow(first)).toHaveCount(1, { timeout: 15000 });
await expect(metrics.historyRow(second)).toHaveCount(1, { timeout: 15000 });
// Star it: the toggle flips, and the entry survives "starred only".
// Star one: the toggle flips, and "starred only" keeps it and drops the unstarred one.
const starred = page.waitForResponse(isHistoryCall('PATCH'));
await metrics.historyStarButton(first).click();
expect((await starred).ok()).toBe(true);
await expect(metrics.historyStarButton(first)).toHaveAttribute('aria-label', 'Unstar');
const starredOnlyBox = metrics.historyStarredOnly.locator('[role="checkbox"]');
// The new filter shows a skeleton until its list answers, and a skeleton has no rows to find.
const starredList = page.waitForResponse(
(res) => {
const params = new URL(res.url()).searchParams;
return isHistoryCall('GET')(res) && params.get('starred') === 'true' && !params.has('count_only');
},
{ timeout: 30000 },
);
await metrics.historyStarredOnly.click();
await expect(starredOnlyBox).toHaveAttribute('aria-checked', 'true');
const starredRows = await (await starredList).json();
expect(starredRows.map((row) => row.query)).toContain(first);
expect(starredRows.map((row) => row.query)).not.toContain(second);
await expect(metrics.historyRow(first)).toHaveCount(1, { timeout: 15000 });
await expect(metrics.historyRow(second)).toHaveCount(0);
await metrics.historyStarredOnly.click();
await page.keyboard.press('Escape');
await expect(metrics.historyTable).toBeHidden();
// Replace the editor's query, then load the starred entry back in place.
await runAndRecord(page, pm, second);
await metrics.openQueryHistory();
await expect(starredOnlyBox).toHaveAttribute('aria-checked', 'false');
await expect(metrics.historyRow(second)).toHaveCount(1, { timeout: 15000 });
// The editor holds the second query; load the starred entry back in place.
await metrics.loadHistoryEntry(first);
await expect(metrics.historyTable).toBeHidden();
await expect(metrics.historyDrawer).toBeHidden();
await expect
.poll(() => pm.metricsQueryEditorPage.getCurrentQueryText(), { timeout: 15000 })
.toBe(first);
@@ -72,7 +85,8 @@ test.describe('Metrics editor query history', () => {
// Starred entries are never reaped, so remove this run's from the user's history.
await metrics.openQueryHistory();
for (const query of [first, second]) {
const deleted = page.waitForResponse(isHistoryCall('DELETE'));
// The DELETE waits for the row's 6s Undo toast to close.
const deleted = page.waitForResponse(isHistoryCall('DELETE'), { timeout: 30000 });
await metrics.deleteHistoryEntry(query);
expect((await deleted).ok()).toBe(true);
await expect(metrics.historyRow(query)).toHaveCount(0, { timeout: 15000 });
@@ -777,6 +777,44 @@ describe("CodeQueryEditor", () => {
expect(wrapper.emitted("update:query")).toBeFalsy();
});
it("passes whether the text is blank and was blank, counting a programmatic restore", async () => {
const lines = (...text: string[]) => {
mockModel.getLineCount.mockReturnValue(text.length);
mockModel.getLineContent.mockImplementation((line: number) => text[line - 1]);
};
const wrapper = await mountAndSetup();
mockEditorObj.setValue.mockImplementation(() => changeHandler()({ isFlush: false }));
lines("level='error'");
const editorApi = wrapper.vm as unknown as { setValue: (value: string) => void };
editorApi.setValue("level='error'");
expect(wrapper.emitted("user-edit")).toBeFalsy();
lines(" ", "");
changeHandler()({ isFlush: false });
lines("", "u");
changeHandler()({ isFlush: false });
expect(wrapper.emitted("user-edit")).toEqual([
[true, false],
[false, true],
]);
lines("");
});
it("reads only up to the first non-blank line on an edit, never the whole document", async () => {
await mountAndSetup();
mockModel.getLineCount.mockReturnValue(10_000);
mockModel.getLineContent.mockImplementation((line: number) => (line === 1 ? "up" : "x"));
mockModel.getLineContent.mockClear();
mockModel.getValue.mockClear();
mockEditorObj.getValue.mockClear();
changeHandler()({ isFlush: false });
expect(mockModel.getLineContent).toHaveBeenCalledTimes(1);
expect(mockModel.getValue).not.toHaveBeenCalled();
expect(mockEditorObj.getValue).not.toHaveBeenCalled();
mockModel.getLineCount.mockReturnValue(1);
mockModel.getLineContent.mockImplementation(() => "");
});
it("ignores a flush (a whole-model replace)", async () => {
const wrapper = await mountAndSetup();
changeHandler()({ isFlush: true });
+35 -2
View File
@@ -102,6 +102,7 @@ const loadMonaco = (): Promise<any> => {
};
import { vrlLanguageDefinition } from "@/utils/query/vrlLanguageDefinition";
import { recentCompletionItems } from "@/utils/queryHistory/recentCompletions";
/**
* Per-editor configuration, keyed by model URI.
@@ -130,6 +131,8 @@ interface EditorConfig {
showHint: (text: string | null) => void;
/** Set when the value lookup searches a caller's own time range instead of the default lookback. */
valuesTimeRange: () => { startTime: number; endTime: number } | null;
/** The user's recent queries, offered as clock rows that replace the editor text. */
recents: () => string[];
}
interface ChainState {
@@ -267,6 +270,15 @@ const editorConfigs = new Map<string, EditorConfig>();
const registeredLanguages = new Set<string>();
const modelKey = (model: any): string => model?.uri?.toString?.() ?? "";
// Stops at the first non-blank line, so an edit never copies the whole document.
const modelIsBlank = (model: any): boolean => {
const lines: number = model?.getLineCount?.() ?? 0;
for (let line = 1; line <= lines; line++) {
if (model.getLineContent(line).trim()) return false;
}
return true;
};
// Global commands report here; the model says which editor accepted.
const onSuggestAccepted = (editor: any, lane: SuggestLane | "", chained: boolean) => {
const config = editorConfigs.get(modelKey(editor?.getModel?.()));
@@ -392,6 +404,10 @@ export default defineComponent({
type: Array,
default: null,
},
recentQueries: {
type: Array as PropType<string[]>,
default: () => [],
},
debounceTime: {
type: Number,
default: 500,
@@ -805,6 +821,7 @@ export default defineComponent({
announce: announceMenu,
showHint,
valuesTimeRange: () => props.valuesTimeRange ?? null,
recents: () => props.recentQueries,
});
};
@@ -1010,7 +1027,11 @@ export default defineComponent({
hintDecorations?.clear();
syncValueQuoteKey();
});
// Programmatic writes update it too, so a restore followed by a clear reads as non-empty → empty.
let isBlank = modelIsBlank(editorObj.getModel());
editorObj.onDidChangeModelContent((e: any) => {
const wasBlank = isBlank;
isBlank = modelIsBlank(editorObj.getModel());
const [change] = e?.changes ?? [];
const chainSpace =
chainState.typingSpace && e?.changes?.length === 1 && change.text === " ";
@@ -1020,7 +1041,7 @@ export default defineComponent({
: null;
if (!e?.isFlush && !programmatic) {
pendingUserEdit = true;
emit("user-edit");
emit("user-edit", isBlank, wasBlank);
} else {
// The debounce emits the latest value, so a write landing on a typed one owns the origin.
pendingUserEdit = false;
@@ -1909,7 +1930,19 @@ export default defineComponent({
if (language === "promql" && context?.triggerCharacter === "=")
return { suggestions: [] };
return reply(catalogEntries(config, model, position, textUntilPosition, word.word));
const catalog = reply(
catalogEntries(config, model, position, textUntilPosition, word.word),
);
const recents = recentCompletionItems(
config.recents(),
model,
position,
kinds().Reference,
t("recentSearches.recentRow"),
);
return recents.length
? { ...catalog, suggestions: [...recents, ...catalog.suggestions] }
: catalog;
},
});
+4 -2
View File
@@ -99,8 +99,9 @@
:free-text-decorations="freeTextDecorations ?? null"
:filter-mode="filterMode"
:values-time-range="valuesTimeRange ?? null"
:recent-queries="recentQueries ?? []"
@update:query="handleQueryUpdate"
@user-edit="emit('user-edit')"
@user-edit="(isBlank: boolean, wasBlank: boolean) => emit('user-edit', isBlank, wasBlank)"
@run-query="emit('run-query')"
@focus="handleEditorFocus"
@blur="handleEditorBlur"
@@ -189,6 +190,7 @@ interface Props {
freeTextDecorations?: { ranges: { start: number; end: number }[]; hover: I18nText } | null;
filterMode?: boolean; // The query is a bare WHERE fragment (Logs without SQL mode)
valuesTimeRange?: { startTime: number; endTime: number } | null; // Range field values are looked up in (microseconds)
recentQueries?: string[]; // The user's recent queries, offered as clock rows while typing
// NL Mode (optional external control)
nlpMode?: boolean; // External NLP mode control (undefined = internal control)
@@ -226,7 +228,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
"update:query": [query: string, origin?: QueryChangeOrigin];
"user-edit": [];
"user-edit": [isBlank: boolean, wasBlank: boolean];
"language-change": [language: Language];
"ask-ai": [naturalLanguage: string, language: Language];
"run-query": [];
@@ -99,6 +99,7 @@ const mockChartData = ref(undefined as any);
const mockShowLegendsDialog = ref(false);
const mockNoticeProps = ref<Record<string, unknown>>({});
const mockRunQuery = vi.fn();
const mockHandleChartApiError = vi.fn();
// Mock usePanelEditor composable
vi.mock("./composables/usePanelEditor", () => ({
@@ -129,7 +130,7 @@ vi.mock("./composables/usePanelEditor", () => ({
currentPanelData: ref({}),
initChartData: vi.fn(),
runQuery: mockRunQuery,
handleChartApiError: vi.fn(),
handleChartApiError: mockHandleChartApiError,
handleLastTriggeredAtUpdate: vi.fn(),
handleLimitNumberOfSeriesWarningMessage: vi.fn(),
handleIsPartialDataUpdate: vi.fn(),
@@ -1038,4 +1039,26 @@ describe("PanelEditor.vue", () => {
]);
});
});
describe("renderer errors", () => {
beforeEach(() => {
mockChartData.value = {};
mockHandleChartApiError.mockClear();
});
it("shows the renderer's error and hands it to the page as renderer-error", () => {
wrapper = shallowMount(PanelEditor, {
props: { pageType: "metrics" },
global: mountGlobal,
});
const renderer = wrapper.findComponent({ name: "PanelSchemaRenderer" });
const failure = { message: "parse error: unexpected <EOF>", code: 400 };
renderer.vm.$emit("error", failure);
expect(mockHandleChartApiError).toHaveBeenCalledWith(failure);
expect(wrapper.emitted("renderer-error")).toEqual([[failure]]);
renderer.vm.$emit("error", { message: "", code: "" });
expect(wrapper.emitted("renderer-error")?.[1]).toEqual([{ message: "", code: "" }]);
});
});
});
@@ -325,7 +325,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
handleLimitNumberOfSeriesWarningMessage
"
@sparkline-warning-update="handleSparklineWarningUpdate"
@error="handleChartApiError"
@error="onRendererError"
@updated:data-zoom="handleDataZoom"
@updated:vrl-function-field-list="updateVrlFunctionFieldList"
@last-triggered-at-update="handleLastTriggeredAtUpdate"
@@ -679,7 +679,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
handleLimitNumberOfSeriesWarningMessage
"
@sparkline-warning-update="handleSparklineWarningUpdate"
@error="handleChartApiError"
@error="onRendererError"
@updated:data-zoom="handleDataZoom"
@updated:vrl-function-field-list="updateVrlFunctionFieldList"
@last-triggered-at-update="handleLastTriggeredAtUpdate"
@@ -1347,6 +1347,12 @@ const handleVariablesDataUpdated = (data: PanelEditorVariablesData) => {
emit("variablesDataUpdated", data);
};
// The editor shows the error itself; pages that classify a run (Metrics history) need it too.
const onRendererError = (error: { message?: unknown; code?: unknown } | string | null) => {
handleChartApiError(error);
emit("renderer-error", error);
};
const handleDataZoom = (event: any) => {
const result = onDataZoom(event);
emit("dataZoom", {
@@ -355,6 +355,9 @@ export interface PanelEditorEmits {
*/
(e: "chartApiError", error: any): void;
/** Every change of the renderer's error; `{ message: "" }` means the last run succeeded. */
(e: "renderer-error", error: { message?: unknown; code?: unknown } | string | null): void;
/**
* Emitted when user zooms on chart (for datetime update).
* Parent should update its DateTimePicker with new time range.
@@ -15,7 +15,7 @@
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { reactive, nextTick } from "vue";
import { computed, reactive, nextTick } from "vue";
const spies = vi.hoisted(() => ({
updateAllKeywords: vi.fn(),
@@ -93,6 +93,7 @@ import DashboardQueryEditor from "@/components/dashboards/addPanel/DashboardQuer
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
import { RECENT_SEARCHES_EDITOR_HOST } from "@/composables/queryHistory/useRecentSearchesEditorHost";
// Panel pages store Date objects whose getTime() is already in microseconds.
const MINUTE_US = 60_000_000;
@@ -283,4 +284,45 @@ describe("DashboardQueryEditor completion inputs", () => {
expect(spies.setValuesTimeRange).toHaveBeenLastCalledWith(null);
});
});
describe("recent searches from the Metrics page", () => {
const QueryEditorStub = {
name: "QueryEditor",
props: ["recentQueries"],
emits: ["focus", "user-edit"],
template: "<div />",
};
const mountWith = (provide: Record<string | symbol, unknown>) =>
mount(DashboardQueryEditor, {
global: {
plugins: [i18n, store, router],
provide: { dashboardPanelDataPageKey: "metrics", ...provide },
stubs: { QueryTypeSelector: true, QueryEditor: QueryEditorStub },
},
});
it("offers the page's recent queries and reports focus and edits", async () => {
const host = {
queries: computed(() => ["rate(http_requests_total[5m])"]),
onFocus: vi.fn(),
onEdit: vi.fn(),
onKeydown: vi.fn(),
};
wrapper = mountWith({ [RECENT_SEARCHES_EDITOR_HOST as symbol]: host });
await flushPromises();
const editor = wrapper.findComponent({ name: "QueryEditor" });
expect(editor.props("recentQueries")).toEqual(["rate(http_requests_total[5m])"]);
editor.vm.$emit("focus");
expect(host.onFocus).toHaveBeenCalledWith(expect.any(HTMLElement));
editor.vm.$emit("user-edit");
expect(host.onEdit).toHaveBeenCalled();
});
it("offers nothing on a dashboard, which provides no host", async () => {
wrapper = mountWith({});
await flushPromises();
expect(wrapper.findComponent({ name: "QueryEditor" }).props("recentQueries")).toEqual([]);
});
});
});
@@ -257,7 +257,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="bg-border-default h-full w-1 transition-colors hover:bg-[orange]"></div>
</template>
<template #before>
<div class="relative h-full w-full">
<div
ref="queryEditorBoxRef"
class="relative h-full w-full"
@keydown.capture="recentHost?.onKeydown($event)"
>
<UnifiedQueryEditor
ref="queryEditorRef"
:languages="['sql', 'promql']"
@@ -275,8 +279,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:suggestions="currentEditorSuggestions"
:field-value-resolver="resolveFieldValues"
:values-time-range="valuesTimeRange"
:recent-queries="recentQueries"
@update:query="handleQueryUpdate"
@focus="_sqlOnFocus"
@user-edit="
(isBlank: boolean, wasBlank: boolean) => recentHost?.onEdit(isBlank, wasBlank)
"
@focus="onQueryEditorFocus"
@blur="_sqlOnBlur"
@language-change="handleLanguageChange"
@ask-ai="handleAskAI"
@@ -411,6 +419,7 @@ import OSplitter from "@/lib/core/Splitter/OSplitter.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import useBreakpoint from "@/composables/useBreakpoint";
import { RECENT_SEARCHES_EDITOR_HOST } from "@/composables/queryHistory/useRecentSearchesEditorHost";
import {
formulaInputs,
formulaRefs,
@@ -462,6 +471,9 @@ export default defineComponent({
const { showErrorNotification, showPositiveNotification } = useNotifications();
const store = useStore();
const dashboardPanelDataPageKey = inject("dashboardPanelDataPageKey", "dashboard");
// Only the Metrics page provides it; dashboards get no recents.
const recentHost = inject(RECENT_SEARCHES_EDITOR_HOST, null);
const queryEditorBoxRef = ref<HTMLElement | null>(null);
const { getAllFunctions } = useFunctions();
const functionList = ref<FunctionListItem[]>([]);
@@ -1157,10 +1169,21 @@ export default defineComponent({
editingQueryName.value = "";
};
const recentQueries = computed(() => recentHost?.queries.value ?? []);
const onQueryEditorFocus = () => {
_sqlOnFocus();
recentHost?.onFocus(queryEditorBoxRef.value);
};
return {
raw,
t,
router,
recentHost,
queryEditorBoxRef,
recentQueries,
onQueryEditorFocus,
onDropDownClick,
promqlMode,
dashboardPanelData,
@@ -0,0 +1,37 @@
// 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, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import store from "@/test/unit/helpers/store";
vi.mock("@/services/query_history", () => ({
default: { list: vi.fn().mockResolvedValue({ data: [] }) },
}));
import RecentSearchesButton from "./RecentSearchesButton.vue";
describe("RecentSearchesButton", () => {
it("puts a caller's class on the clock button, since the popover renders no element", () => {
const wrapper = mount(RecentSearchesButton, {
props: { source: "logs", dataTest: "clock" },
attrs: { class: "me-1" },
global: { plugins: [store], stubs: { RecentSearchesPopover: true, OTooltip: true } },
});
const button = wrapper.find('[data-test="clock"]');
expect(button.exists()).toBe(true);
expect(button.classes()).toContain("me-1");
});
});
@@ -0,0 +1,112 @@
<!-- 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/>.
-->
<template>
<OPopover
v-if="enabled"
v-model:open="open"
side="bottom"
align="end"
:aria-label="t('recentSearches.title')"
@open-auto-focus="focusList"
@pointer-down-outside="keepForDetails"
@focus-outside="keepForDetails"
>
<template #trigger>
<OButton
variant="outline"
size="icon-toolbar"
icon-left="history"
v-bind="$attrs"
:aria-label="t('recentSearches.title')"
:data-test="dataTest"
@keydown="onTriggerKeydown"
>
<OTooltip :content="t('recentSearches.title')" />
</OButton>
</template>
<RecentSearchesPopover
ref="popoverRef"
:source="source"
:active="open"
:examples="examples"
@run="(entry, when) => close(() => emit('run', entry, when))"
@load="(entry, when) => close(() => emit('load', entry, when))"
@view-all="(status) => close(() => emit('view-all', status))"
@example="(query) => close(() => emit('example', query))"
@close="open = false"
/>
</OPopover>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useI18nTyped } from "@/types/i18n";
import { useRecentSearches } from "@/composables/queryHistory/useRecentSearches";
import type { HistorySource, HistoryStatus, QueryHistoryEntry } from "@/services/query_history";
import type { RestoreTime } from "@/utils/queryHistory/recentSearches";
import RecentSearchesPopover from "./RecentSearchesPopover.vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
// OPopover renders no element of its own, so a caller's class and attributes go on the trigger.
defineOptions({ inheritAttrs: false });
withDefaults(
defineProps<{
source: HistorySource;
examples?: string[];
dataTest?: string;
}>(),
{ examples: () => [], dataTest: "recent-searches-btn" },
);
const emit = defineEmits<{
run: [entry: QueryHistoryEntry, when: RestoreTime];
load: [entry: QueryHistoryEntry, when: RestoreTime];
"view-all": [status: HistoryStatus | null];
example: [query: string];
}>();
const { t } = useI18nTyped();
const { enabled } = useRecentSearches();
const open = ref(false);
const popoverRef = ref<InstanceType<typeof RecentSearchesPopover> | null>(null);
// Focus can stay on the clock button after opening, and the list's keys must still reach the rows.
const onTriggerKeydown = (event: KeyboardEvent) => {
if (open.value && popoverRef.value?.handleOutsideKey(event)) event.preventDefault();
};
// The details pane is teleported outside the popover's content, so reka counts a click in it as outside.
const keepForDetails = (event: Event) => {
const original = (event as CustomEvent<{ originalEvent?: Event }>).detail?.originalEvent;
const target = (original?.target ?? event.target) as Node | null;
if (popoverRef.value?.ownsNode(target)) event.preventDefault();
};
// OPopover would focus the info button, where Enter opens nothing and the hovered row never runs.
const focusList = (event: Event) => {
event.preventDefault();
popoverRef.value?.focusRoot();
};
const close = (then: () => void) => {
open.value = false;
then();
};
</script>
@@ -0,0 +1,129 @@
// 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 { afterEach, describe, expect, it, vi } from "vitest";
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
import { DateTime } from "luxon";
import i18n from "@/locales";
import RecentSearchesDayPicker from "./RecentSearchesDayPicker.vue";
enableAutoUnmount(afterEach);
const mountPicker = (modelValue?: string, zone?: string) =>
mount(RecentSearchesDayPicker, {
props: {
modelValue,
zone,
placeholder: "dd/mm/yyyy",
fieldLabel: "Filter by date run",
dataTest: "ran-on",
},
attachTo: document.body,
global: { plugins: [i18n] },
});
const cell = (iso: string) =>
document.querySelector<HTMLElement>(`[data-test="daterangecalendar-cell-${iso}"]`);
const openCalendar = async (wrapper: ReturnType<typeof mountPicker>) => {
await wrapper.find('[data-test="ran-on-trigger"]').trigger("click");
await flushPromises();
};
describe("RecentSearchesDayPicker", () => {
it("reads as a date field: the dd/mm/yyyy placeholder, then the chosen day in that format", () => {
const empty = mountPicker();
const emptyValue = empty.find('[data-test="ran-on-value"]');
expect(emptyValue.text()).toBe("dd/mm/yyyy");
expect(emptyValue.classes()).toContain("text-input-placeholder");
expect(empty.find('[data-test="ran-on-trigger"]').attributes("aria-label")).toBe(
"Filter by date run",
);
const picked = mountPicker("2026-10-29").find('[data-test="ran-on-value"]');
expect(picked.text()).toBe("29/10/2026");
expect(picked.classes()).toContain("text-input-text");
});
it("opens the shared range calendar and picks one day with a single click", async () => {
const wrapper = mountPicker();
await openCalendar(wrapper);
expect(document.querySelector('[data-test="daterangecalendar-root"]')).not.toBeNull();
const today = DateTime.local().toISODate()!;
cell(today)!.click();
await flushPromises();
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([today]);
expect(document.querySelector('[data-test="ran-on-calendar"]')).toBeNull();
});
it("marks the chosen day selected and keeps days after today unavailable", async () => {
const today = DateTime.local();
const wrapper = mountPicker(today.toISODate()!);
await openCalendar(wrapper);
expect(cell(today.toISODate()!)?.hasAttribute("data-selected")).toBe(true);
const later = [
...document.querySelectorAll<HTMLElement>('[data-test^="daterangecalendar-cell-"]'),
].filter((el) => el.dataset.test!.replace("daterangecalendar-cell-", "") > today.toISODate()!);
later.forEach((el) => expect(el.hasAttribute("data-disabled")).toBe(true));
});
it("offers the new day after midnight without a remount", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
vi.setSystemTime(new Date(2026, 9, 28, 23, 59));
const wrapper = mountPicker();
await openCalendar(wrapper);
expect(cell("2026-10-29")?.hasAttribute("data-disabled")).toBe(true);
await wrapper.find('[data-test="ran-on-trigger"]').trigger("click");
await flushPromises();
vi.setSystemTime(new Date(2026, 9, 29, 0, 1));
await openCalendar(wrapper);
expect(cell("2026-10-29")?.hasAttribute("data-disabled")).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("ends the pickable days at today in the given timezone", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
vi.setSystemTime(new Date(Date.UTC(2026, 9, 28, 20, 0)));
await openCalendar(mountPicker(undefined, "Asia/Kolkata"));
expect(cell("2026-10-29")?.hasAttribute("data-disabled")).toBe(false);
expect(cell("2026-10-30")?.hasAttribute("data-disabled")).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("joins the field and its clear button in a button group, without overriding the buttons' box", () => {
const wrapper = mountPicker("2026-10-27");
const group = wrapper.find('[data-test="ran-on"]');
expect(group.attributes("role")).toBe("group");
const buttons = group.findAll(":scope > button");
expect(buttons.map((b) => b.attributes("data-test"))).toEqual([
"ran-on-trigger",
"ran-on-clear",
]);
const forced = /^(h|rounded|border)(-\S*)?!$/;
buttons.forEach((b) => expect(b.classes().filter((c) => forced.test(c))).toEqual([]));
});
it("clears the day with its own button, shown only while a day is set", async () => {
expect(mountPicker().find('[data-test="ran-on-clear"]').exists()).toBe(false);
const wrapper = mountPicker("2026-10-27");
await wrapper.find('[data-test="ran-on-clear"]').trigger("click");
expect(wrapper.emitted("update:modelValue")?.at(-1)).toEqual([undefined]);
});
});
@@ -0,0 +1,136 @@
<!-- 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/>.
-->
<template>
<OButtonGroup
class="border-input-border bg-input-bg hover:border-input-border-hover border"
:data-test="dataTest"
>
<OPopover
v-model:open="open"
side="bottom"
align="start"
:aria-label="fieldLabel"
content-class="p-1 shadow-lg!"
@open-auto-focus="focusDay"
>
<template #trigger>
<OButton
variant="ghost"
size="xs"
class="min-w-36"
:aria-label="fieldLabel"
:data-test="`${dataTest}-trigger`"
>
<span
:class="modelValue ? 'text-input-text' : 'text-input-placeholder'"
class="ms-0.5 flex-1 text-left text-sm font-normal tabular-nums"
:data-test="`${dataTest}-value`"
>{{ label }}</span
>
<template #icon-right>
<OIcon name="calendar-month" size="sm" class="text-input-hint ms-auto me-0.5" />
</template>
</OButton>
</template>
<div
ref="calendarRef"
class="flex justify-center px-3 py-2"
:data-test="`${dataTest}-calendar`"
>
<ODateRangeCalendar
:start-date="selectedDay"
:end-date="selectedDay"
:min-date="MIN_DAY"
:max-date="today"
@update:pending-date="pick"
/>
</div>
</OPopover>
<OButton
v-if="modelValue"
variant="ghost"
size="icon-xs-sq"
icon-left="close"
:aria-label="t('common.clearFilter')"
:data-test="`${dataTest}-clear`"
@click="emit('update:modelValue', undefined)"
/>
</OButtonGroup>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { DateTime } from "luxon";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OButtonGroup from "@/lib/core/Button/OButtonGroup.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import ODateRangeCalendar from "@/lib/forms/DateTimeRange/ODateRangeCalendar.vue";
const MIN_DAY = "1999/01/01";
const props = defineProps<{
/** The chosen day as YYYY-MM-DD, or undefined for any day. */
modelValue?: string;
/** Shown when no day is chosen, in the format the field displays. */
placeholder: I18nText;
fieldLabel: I18nText;
dataTest: string;
/** Timezone whose today is the last pickable day; the browser's when unset. */
zone?: string;
}>();
const emit = defineEmits<{
"update:modelValue": [day: string | undefined];
}>();
const { t } = useI18nTyped();
const open = ref(false);
const calendarRef = ref<HTMLElement | null>(null);
const todayText = () =>
(props.zone ? DateTime.now().setZone(props.zone) : DateTime.local()).toFormat("yyyy/MM/dd");
const today = ref(todayText());
// Read on every open, so a drawer left open past midnight still offers the new day.
watch(open, (isOpen) => {
if (isOpen) today.value = todayText();
});
// The calendar speaks the app's YYYY/MM/DD; the filter keeps YYYY-MM-DD.
const selectedDay = computed(() => props.modelValue?.replace(/-/g, "/"));
const label = computed(() => {
if (!props.modelValue) return props.placeholder;
return raw(DateTime.fromISO(props.modelValue).toFormat("dd/MM/yyyy"));
});
// Arrow keys and Enter work only from a day cell, so open on the chosen day, else today.
const focusDay = (event: Event) => {
const day =
calendarRef.value?.querySelector<HTMLElement>("[data-selected]") ??
calendarRef.value?.querySelector<HTMLElement>("[data-today]");
if (!day) return;
event.preventDefault();
day.focus();
};
// A first click on the range calendar is one complete day for this filter.
const pick = (day: string | null) => {
if (!day) return;
emit("update:modelValue", day.replace(/\//g, "-"));
open.value = false;
};
</script>
@@ -0,0 +1,321 @@
<!-- 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/>.
-->
<template>
<Teleport to="body">
<div
v-if="place"
ref="paneRef"
class="bg-select-content-bg border-dropdown-border rounded-default text-select-item-text fixed flex flex-col overflow-hidden border shadow-lg"
:style="paneStyle"
data-test="recent-searches-details"
@pointerenter="onEnter"
@pointerleave="onLeave"
@focusout="onFocusOut"
@keydown="onKeydown"
>
<div class="relative flex min-h-0 flex-1 flex-col">
<pre
ref="queryRef"
class="m-0 min-h-0 flex-1 overflow-auto p-2 font-mono text-xs wrap-anywhere whitespace-pre-wrap"
data-test="recent-searches-details-query"
@scroll="updateFade"
>{{ entry.query }}</pre>
<div
v-if="moreBelow"
class="from-select-content-bg pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-linear-to-t to-transparent"
data-test="recent-searches-details-fade"
/>
</div>
<div
v-if="vrl"
class="border-border-subtle flex max-h-32 shrink-0 flex-col border-t"
data-test="recent-searches-details-function"
>
<div class="flex shrink-0 items-center gap-2 ps-2 pe-1 pt-1">
<span class="text-text-secondary text-2xs flex-1 font-semibold uppercase">{{
t("recentSearches.functionLabel")
}}</span>
<OButton
variant="ghost"
size="icon-xs"
icon-left="content-copy"
:aria-label="t('recentSearches.copyFunction')"
data-test="recent-searches-details-copy-function"
@click="copyFunction"
>
<OTooltip :content="t('recentSearches.copyFunction')" />
</OButton>
</div>
<pre
class="m-0 min-h-0 overflow-auto px-2 pb-2 font-mono text-xs wrap-anywhere whitespace-pre-wrap"
data-test="recent-searches-details-function-text"
>{{ vrl }}</pre>
</div>
<div
class="bg-select-content-bg border-border-subtle flex shrink-0 flex-col gap-1 border-t px-2 py-1"
data-test="recent-searches-details-footer"
>
<div class="flex min-w-0 items-center gap-2">
<div
class="text-text-secondary text-2xs flex min-w-0 flex-1 flex-wrap gap-x-2"
data-test="recent-searches-details-meta"
>
<span data-test="recent-searches-details-lines">{{
t("recentSearches.lineCount", { count: lineCount }, lineCount)
}}</span>
<span v-if="entry.streams.length">{{ entry.streams.join(", ") }}</span>
<span v-if="windowText(entry)">{{ windowText(entry) }}</span>
<span>{{ spanText(entry) }}</span>
<span v-if="entry.status === 'error'" class="text-status-error-text">{{
t("recentSearches.failed")
}}</span>
</div>
<OButton
variant="ghost"
size="icon-xs"
icon-left="content-copy"
class="shrink-0"
:aria-label="t('recentSearches.copyQuery')"
data-test="recent-searches-details-copy"
@click="copyQuery"
>
<OTooltip :content="t('recentSearches.copyQuery')" />
</OButton>
</div>
<div
v-if="entry.status === 'error' && entry.context.error"
class="text-status-error-text text-2xs max-h-16 overflow-auto wrap-anywhere whitespace-pre-wrap"
data-test="recent-searches-details-error"
>
{{ entry.context.error }}
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useI18nTyped } from "@/types/i18n";
import { useRecentEntryText } from "@/composables/queryHistory/useRecentEntryText";
import type { QueryHistoryEntry } from "@/services/query_history";
import { copyToClipboard } from "@/utils/clipboard";
import { logsEntryFunction } from "@/utils/queryHistory/logs";
import { observeMoves } from "@/utils/queryHistory/observeMoves";
import OButton from "@/lib/core/Button/OButton.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
const WIDTH_PX = 400;
const WIDE_WIDTH_PX = 560;
const WIDE_AFTER_LINES = 18;
const MAX_HEIGHT_PX = 320;
const GAP_PX = 4;
const VIEWPORT_GAP_PX = 8;
const Z_INDEX = 10050;
const PAGE_OVERLAP_PX = 16;
const props = defineProps<{
entry: QueryHistoryEntry;
anchor: HTMLElement | null;
}>();
const emit = defineEmits<{
enter: [];
leave: [event: PointerEvent];
/** Focus left the pane, for the node it went to. */
"focus-leave": [to: Node | null];
/** Shift+Tab out of the pane, back to the row it details. */
back: [];
escape: [];
}>();
const { t } = useI18nTyped();
const { windowText, spanText } = useRecentEntryText();
const paneRef = ref<HTMLElement | null>(null);
const queryRef = ref<HTMLElement | null>(null);
const pointerInside = ref(false);
const onEnter = () => {
pointerInside.value = true;
emit("enter");
};
const onLeave = (event: PointerEvent) => {
pointerInside.value = false;
emit("leave", event);
};
const onFocusOut = (event: FocusEvent) => {
const to = event.relatedTarget as Node | null;
if (!paneRef.value?.contains(to)) emit("focus-leave", to);
};
// The pane is teleported after the whole page, so the browser's own Shift+Tab would leave the list.
const onKeydown = (event: KeyboardEvent) => {
if (event.key === "Tab" && event.shiftKey) {
event.preventDefault();
emit("back");
} else if (event.key === "Escape") {
event.preventDefault();
emit("escape");
}
};
const lineCount = computed(() => props.entry.query.split("\n").length);
const vrl = computed(() => logsEntryFunction(props.entry));
const wantedWidth = computed(() => (lineCount.value > WIDE_AFTER_LINES ? WIDE_WIDTH_PX : WIDTH_PX));
interface Place {
top: number;
left: number;
width: number;
maxHeight: number;
beside: boolean;
}
const place = ref<Place | null>(null);
const paneStyle = computed(() => {
const at = place.value;
if (!at) return {};
return {
top: `${at.top}px`,
left: `${at.left}px`,
width: `${at.width}px`,
maxHeight: `${at.maxHeight}px`,
zIndex: Z_INDEX,
};
});
const compute = (): Place | null => {
const box = props.anchor?.getBoundingClientRect();
if (!box) return null;
const roomRight = window.innerWidth - VIEWPORT_GAP_PX - (box.right + GAP_PX);
const roomLeft = box.left - GAP_PX - VIEWPORT_GAP_PX;
const sideTop = Math.max(VIEWPORT_GAP_PX, box.top);
const sideMaxHeight = Math.min(MAX_HEIGHT_PX, window.innerHeight - 2 * VIEWPORT_GAP_PX);
if (roomRight >= WIDTH_PX) {
const width = Math.min(wantedWidth.value, roomRight);
return {
top: sideTop,
left: box.right + GAP_PX,
width,
maxHeight: sideMaxHeight,
beside: true,
};
}
if (roomLeft >= WIDTH_PX) {
const width = Math.min(wantedWidth.value, roomLeft);
const left = box.left - GAP_PX - width;
return { top: sideTop, left, width, maxHeight: sideMaxHeight, beside: true };
}
const width = Math.min(wantedWidth.value, window.innerWidth - 2 * VIEWPORT_GAP_PX);
const left = Math.max(
VIEWPORT_GAP_PX,
Math.min(box.left, window.innerWidth - VIEWPORT_GAP_PX - width),
);
const top = box.bottom + GAP_PX;
const maxHeight = Math.min(MAX_HEIGHT_PX, window.innerHeight - top - VIEWPORT_GAP_PX);
return maxHeight > 0 ? { top, left, width, maxHeight, beside: false } : null;
};
const liftIntoView = () => {
const at = place.value;
const height = paneRef.value?.offsetHeight ?? 0;
if (!at?.beside || !height) return;
const overflow = at.top + height - (window.innerHeight - VIEWPORT_GAP_PX);
if (overflow > 0) place.value = { ...at, top: Math.max(VIEWPORT_GAP_PX, at.top - overflow) };
};
const moreBelow = ref(false);
const updateFade = () => {
const el = queryRef.value;
moreBelow.value = !!el && el.scrollTop + el.clientHeight < el.scrollHeight - 1;
};
const reposition = () => {
place.value = compute();
nextTick(() => {
liftIntoView();
updateFade();
});
};
let placedKey = "";
let stopObserving: (() => void) | null = null;
const anchorKey = () => {
const box = props.anchor?.getBoundingClientRect();
if (!box) return "";
return `${box.left}|${box.top}|${box.right}|${box.bottom}|${window.innerWidth}|${window.innerHeight}`;
};
// The list moves with the caret, the page and the popover; only a real move re-places the pane.
const follow = () => {
const key = anchorKey();
if (key === placedKey) return;
placedKey = key;
reposition();
};
watch(
() => props.entry,
() => {
if (queryRef.value) queryRef.value.scrollTop = 0;
reposition();
},
);
onMounted(() => {
placedKey = anchorKey();
reposition();
if (props.anchor) stopObserving = observeMoves(props.anchor, follow);
});
onBeforeUnmount(() => stopObserving?.());
const copyQuery = () =>
copyToClipboard(props.entry.query, t, { successMessage: t("recentSearches.queryCopied") });
const copyFunction = () =>
copyToClipboard(vrl.value, t, { successMessage: t("recentSearches.functionCopied") });
/** Scrolls the query one page down (1) or up (-1); false when there is nothing to scroll. */
const scrollPage = (step: 1 | -1) => {
const el = queryRef.value;
if (!el) return false;
el.scrollTop += step * Math.max(el.clientHeight - PAGE_OVERLAP_PX, PAGE_OVERLAP_PX);
updateFade();
return true;
};
/** Whether a node is inside the pane, which lives outside the list's own DOM. */
const contains = (node: Node | null) => !!node && !!paneRef.value?.contains(node);
const hasFocus = () => contains(document.activeElement);
const hasPointer = () => pointerInside.value;
/** Moves keyboard focus to the Copy button; false when the pane is not shown. */
const focusCopy = () => {
const button = paneRef.value?.querySelector<HTMLElement>(
'[data-test="recent-searches-details-copy"]',
);
if (!button) return false;
button.focus();
return true;
};
defineExpose({ scrollPage, contains, hasFocus, hasPointer, focusCopy });
</script>
@@ -0,0 +1,720 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
import { nextTick } from "vue";
import store from "@/test/unit/helpers/store";
import type { QueryHistoryEntry } from "@/services/query_history";
import { b64EncodeUnicode } from "@/utils/formatters";
const api = vi.hoisted(() => ({
list: vi.fn(),
record: vi.fn(),
star: vi.fn(),
remove: vi.fn(),
clear: vi.fn(),
count: vi.fn(),
}));
vi.mock("@/services/query_history", () => ({ default: api }));
const clipboard = vi.hoisted(() => ({ copyToClipboard: vi.fn() }));
vi.mock("@/utils/clipboard", () => clipboard);
const push = vi.hoisted(() => vi.fn());
vi.mock("vue-router", () => ({
useRouter: () => ({
push,
resolve: (to: { path: string; query: Record<string, string> }) => ({
href: `/web${to.path}?${new URLSearchParams(to.query).toString()}`,
}),
}),
}));
import i18n from "@/locales";
import RecentSearchesDrawer from "./RecentSearchesDrawer.vue";
import { dismissAll, pauseTimer, toastRecords } from "@/lib/feedback/Toast/useToast";
enableAutoUnmount(afterEach);
const NOW_US = Date.now() * 1000;
const DAY_US = 86_400_000_000;
const row = (id: string, overrides: Partial<QueryHistoryEntry> = {}): QueryHistoryEntry => ({
id,
source: "logs",
query: `query ${id}`,
query_key: "k",
streams: ["k8s"],
context: { time: { type: "relative", period: "15m", start_time: 1, end_time: 2 } },
status: "ok",
starred: false,
run_count: 1,
created_at: NOW_US,
updated_at: NOW_US,
...overrides,
});
const input = (name: string) => ({
props: ["modelValue"],
emits: ["update:modelValue"],
template: `<input data-stub="${name}" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`,
});
const mountDrawer = (open = true) =>
mount(RecentSearchesDrawer, {
props: { source: "logs", open },
global: {
plugins: [store],
stubs: {
ODrawer: {
props: ["open"],
template: `<div v-if="open"><slot name="header-left" /><slot /><slot name="footer" /></div>`,
},
ODialog: {
props: ["open"],
emits: ["click:primary", "click:secondary"],
template: `<div v-if="open" data-test="dialog"><slot />
<button data-test="dialog-confirm" @click="$emit('click:primary')" /></div>`,
},
// The real menu is portalled, so its clicks never reach the row.
ODropdown: { template: `<div @click.stop><slot name="trigger" /><slot /></div>` },
ODropdownItem: {
emits: ["select"],
template: `<button v-bind="$attrs" @click="$emit('select')"><slot /></button>`,
},
OSearchInput: input("search"),
OSelect: input("select"),
RecentSearchesDayPicker: input("date"),
OSwitch: {
props: ["modelValue"],
emits: ["update:modelValue"],
template: `<input type="checkbox" :checked="modelValue" @change="$emit('update:modelValue', $event.target.checked)" />`,
},
OCheckbox: {
props: ["modelValue"],
emits: ["update:modelValue"],
template: `<input type="checkbox" :checked="modelValue" @change="$emit('update:modelValue', $event.target.checked)" />`,
},
OToggleGroup: {
props: ["modelValue"],
emits: ["update:modelValue"],
template: `<div><button data-test="scope-all" @click="$emit('update:modelValue', 'all')" /><slot /></div>`,
},
OToggleGroupItem: { template: "<span><slot /></span>" },
OTimeCell: true,
OTooltip: true,
},
},
});
const lastParams = () => api.list.mock.calls.at(-1)?.[1];
describe("RecentSearchesDrawer", () => {
beforeEach(() => {
vi.clearAllMocks();
api.list.mockResolvedValue({
data: [row("today"), row("old", { updated_at: NOW_US - 3 * DAY_US, run_count: 4 })],
});
api.star.mockResolvedValue({ data: {} });
api.remove.mockResolvedValue({ data: {} });
api.clear.mockResolvedValue({ data: { deleted: 2 } });
});
afterEach(() => {
store.state.zoConfig.query_history_retention_days = undefined;
});
it("lists this page's rows grouped by day", async () => {
const wrapper = mountDrawer();
await flushPromises();
expect(lastParams()).toEqual({ source: "logs", limit: 50, offset: 0 });
const days = wrapper.findAll('[data-test="recent-searches-day"]');
expect(days).toHaveLength(2);
expect(days[0].text()).toContain("Today");
expect(days[1].text()).toContain("ran 4x");
});
it("sends every filter to the server", async () => {
const wrapper = mountDrawer();
await flushPromises();
await wrapper.find('[data-stub="search"]').setValue("checkout");
const selects = wrapper.findAll('[data-stub="select"]');
await selects[0].setValue("k8s");
await selects[1].setValue("error");
await wrapper.find('[data-stub="date"]').setValue("2026-10-27");
await wrapper.find('[data-test="recent-searches-starred-only"]').setValue(true);
await flushPromises();
const bounds = {
ran_from: new Date(2026, 9, 27).getTime() * 1000,
ran_to: new Date(2026, 9, 28).getTime() * 1000 - 1,
};
expect(lastParams()).toEqual({
source: "logs",
q: "checkout",
stream: "k8s",
status: "error",
starred: true,
...bounds,
limit: 50,
offset: 0,
});
await wrapper.find('[data-test="scope-all"]').trigger("click");
await flushPromises();
expect(lastParams().source).toBe("logs,traces,metrics");
});
describe("Reset filters", () => {
const RESET = '[data-test="recent-searches-reset-filters"]';
// Opened already filtered, so the unfiltered list is never cached and Reset must fetch it.
const mountFiltered = async () => {
const wrapper = mountDrawer(false);
wrapper.vm.show("error");
await wrapper.setProps({ open: true });
await flushPromises();
await wrapper.find('[data-stub="search"]').setValue("checkout");
await wrapper.findAll('[data-stub="select"]')[0].setValue("k8s");
await wrapper.find('[data-stub="date"]').setValue("2026-10-27");
await wrapper.find('[data-test="recent-searches-starred-only"]').setValue(true);
await flushPromises();
expect(api.list.mock.calls.every(([, params]) => params.status === "error")).toBe(true);
return wrapper;
};
const expectCleared = (wrapper: ReturnType<typeof mountDrawer>) => {
expect((wrapper.find('[data-stub="search"]').element as HTMLInputElement).value).toBe("");
const selects = wrapper.findAll('[data-stub="select"]');
expect((selects[0].element as HTMLInputElement).value).toBe("");
expect((selects[1].element as HTMLInputElement).value).toBe("all");
expect((wrapper.find('[data-stub="date"]').element as HTMLInputElement).value).toBe("");
expect(
(wrapper.find('[data-test="recent-searches-starred-only"]').element as HTMLInputElement)
.checked,
).toBe(false);
expect(lastParams()).toEqual({ source: "logs", limit: 50, offset: 0 });
expect(wrapper.find(RESET).exists()).toBe(false);
};
it("is hidden with no filter and shows once any one filter is set", async () => {
const wrapper = mountDrawer();
await flushPromises();
expect(wrapper.find(RESET).exists()).toBe(false);
await wrapper.find('[data-stub="date"]').setValue("2026-10-27");
expect(wrapper.find(RESET).exists()).toBe(true);
await wrapper.find('[data-stub="date"]').setValue("");
await wrapper.findAll('[data-stub="select"]')[1].setValue("ok");
expect(wrapper.find(RESET).exists()).toBe(true);
});
it("clears all five filters and refetches unfiltered", async () => {
const wrapper = await mountFiltered();
await wrapper.find(RESET).trigger("click");
await flushPromises();
expectCleared(wrapper);
});
it("does the same from the filtered empty state", async () => {
api.list.mockResolvedValue({ data: [] });
const wrapper = await mountFiltered();
const empty = wrapper.find('[data-test="recent-searches-drawer-empty"]');
expect(empty.text()).toContain("No searches match these filters");
const button = empty.findAll("button").filter((b) => b.text().includes("Reset filters"));
expect(button).toHaveLength(1);
await button[0].trigger("click");
await flushPromises();
expectCleared(wrapper);
});
});
it("re-runs now on a row click from today, and at original time on demand", async () => {
const wrapper = mountDrawer();
await flushPromises();
await wrapper.find('[data-test="recent-searches-drawer-row-today"]').trigger("click");
expect(wrapper.emitted("restore")?.[0]).toEqual([
expect.objectContaining({ id: "today" }),
"now",
true,
]);
expect(wrapper.emitted("update:open")?.at(-1)).toEqual([false]);
await wrapper.setProps({ open: false });
await wrapper.setProps({ open: true });
await wrapper.find('[data-test="recent-searches-rerun-original-old"]').trigger("click");
expect(wrapper.emitted("restore")?.[1]?.slice(1)).toEqual(["original", true]);
await wrapper.setProps({ open: false });
await wrapper.setProps({ open: true });
await wrapper.find('[data-test="recent-searches-load-old"]').trigger("click");
expect(wrapper.emitted("restore")?.[2]?.slice(1)).toEqual(["original", false]);
});
it("stars, deletes and copies without restoring", async () => {
const wrapper = mountDrawer();
await flushPromises();
await wrapper.find('[data-test="recent-searches-star-today"]').trigger("click");
await flushPromises();
expect(api.star).toHaveBeenCalledWith("default", "today", true);
await wrapper.find('[data-test="recent-searches-copy-query-old"]').trigger("click");
expect(clipboard.copyToClipboard).toHaveBeenLastCalledWith(
"query old",
expect.any(Function),
expect.any(Object),
);
await wrapper.find('[data-test="recent-searches-copy-link-today"]').trigger("click");
const link = clipboard.copyToClipboard.mock.calls.at(-1)?.[0] as string;
expect(link).toContain("/web/logs?");
expect(link).toContain("from=1");
expect(link).toContain("to=2");
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
await wrapper.find('[data-test="recent-searches-delete-old"]').trigger("click");
await flushPromises();
expect(wrapper.find('[data-test="recent-searches-drawer-row-old"]').exists()).toBe(false);
expect(api.remove).not.toHaveBeenCalled();
vi.advanceTimersByTime(6000);
await flushPromises();
expect(api.remove).toHaveBeenCalledWith("default", "old");
} finally {
vi.useRealTimers();
}
expect(wrapper.emitted("restore")).toBeUndefined();
});
it("keeps a row's VRL function: its own copy, a line under the query, and in the cut query's tooltip", async () => {
const vrl = '.severity = if .status >= 500 {\n "critical"\n} else { "info" }';
api.list.mockResolvedValue({
data: [
row("fn", {
query: "SELECT 1",
context: { page: { stream: "k8s", functionContent: b64EncodeUnicode(vrl) ?? "" } },
}),
row("plain"),
],
});
const wrapper = mount(RecentSearchesDrawer, {
props: { source: "logs", open: true },
global: {
plugins: [store],
stubs: {
ODrawer: { props: ["open"], template: `<div v-if="open"><slot /></div>` },
ODialog: true,
OTimeCell: true,
},
},
attachTo: document.body,
});
await flushPromises();
expect(wrapper.find('[data-test="recent-searches-copy-function-plain"]').exists()).toBe(false);
expect(wrapper.find('[data-test="recent-searches-drawer-function"]').text()).toContain(
"Function (VRL): .severity",
);
const cell = wrapper.find('[data-test="recent-searches-drawer-query"]').element;
Object.defineProperty(cell, "scrollWidth", { configurable: true, value: 1200 });
Object.defineProperty(cell, "clientWidth", { configurable: true, value: 300 });
vi.useFakeTimers();
try {
cell.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(700);
await nextTick();
await nextTick();
const bubble = document.body.querySelector('[data-test="o-tooltip-content"]');
expect(bubble?.textContent).toContain("SELECT 1");
expect(bubble?.textContent).toContain(vrl);
} finally {
vi.useRealTimers();
wrapper.unmount();
}
});
it("copies the function from its own menu item and the query alone from Copy query", async () => {
const vrl = ".a = 1\n.b = 2";
api.list.mockResolvedValue({
data: [
row("fn", {
query: "SELECT 1",
context: { page: { stream: "k8s", functionContent: b64EncodeUnicode(vrl) ?? "" } },
}),
],
});
const wrapper = mountDrawer();
await flushPromises();
await wrapper.find('[data-test="recent-searches-copy-function-fn"]').trigger("click");
expect(clipboard.copyToClipboard).toHaveBeenLastCalledWith(vrl, expect.any(Function), {
successMessage: "Function copied",
});
await wrapper.find('[data-test="recent-searches-copy-query-fn"]').trigger("click");
expect(clipboard.copyToClipboard).toHaveBeenLastCalledWith("SELECT 1", expect.any(Function), {
successMessage: "Query copied",
});
});
describe("delete with Undo", () => {
const deleteToast = () => toastRecords.find((r) => r.message.startsWith("Deleted"));
const rowShown = (wrapper: ReturnType<typeof mountDrawer>) =>
wrapper.find('[data-test="recent-searches-drawer-row-old"]').exists();
const deleteOld = async (wrapper: ReturnType<typeof mountDrawer>) => {
await wrapper.find('[data-test="recent-searches-delete-old"]').trigger("click");
await flushPromises();
};
beforeEach(() => {
dismissAll();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
});
afterEach(() => {
vi.useRealTimers();
dismissAll();
});
it("shows the row again when the server refuses the delete", async () => {
api.remove.mockRejectedValueOnce(new Error("500"));
const wrapper = mountDrawer();
await flushPromises();
await deleteOld(wrapper);
expect(rowShown(wrapper)).toBe(false);
vi.advanceTimersByTime(20000);
await flushPromises();
expect(api.remove).toHaveBeenCalledWith("default", "old");
expect(rowShown(wrapper)).toBe(true);
});
it("brings the row back on Undo and never deletes it", async () => {
const wrapper = mountDrawer();
await flushPromises();
await deleteOld(wrapper);
expect(rowShown(wrapper)).toBe(false);
expect(deleteToast()?.message).toBe("Deleted query old");
deleteToast()?.action?.handler();
await flushPromises();
expect(rowShown(wrapper)).toBe(true);
expect(deleteToast()?.open ?? false).toBe(false);
vi.advanceTimersByTime(20000);
await flushPromises();
expect(api.remove).not.toHaveBeenCalled();
});
it("deletes only when the toast closes, so a paused toast keeps Undo working", async () => {
const wrapper = mountDrawer();
await flushPromises();
await deleteOld(wrapper);
const record = deleteToast();
pauseTimer(record!.id);
vi.advanceTimersByTime(20000);
await flushPromises();
expect(api.remove).not.toHaveBeenCalled();
record?.action?.handler();
await flushPromises();
expect(rowShown(wrapper)).toBe(true);
expect(api.remove).not.toHaveBeenCalled();
});
it("deletes once the toast times out, and Undo then does nothing", async () => {
const wrapper = mountDrawer();
await flushPromises();
await deleteOld(wrapper);
const action = deleteToast()?.action;
vi.advanceTimersByTime(6000);
await flushPromises();
expect(api.remove).toHaveBeenCalledWith("default", "old");
action?.handler();
await flushPromises();
expect(rowShown(wrapper)).toBe(false);
expect(api.remove).toHaveBeenCalledTimes(1);
});
it("deletes in the org the delete started in, after a switch to another org", async () => {
const org = store.state.selectedOrganization;
const wrapper = mountDrawer();
await flushPromises();
await deleteOld(wrapper);
store.state.selectedOrganization = { ...org, identifier: "other" };
try {
vi.advanceTimersByTime(6000);
await flushPromises();
expect(api.remove).toHaveBeenCalledWith("default", "old");
expect(api.remove).not.toHaveBeenCalledWith("other", "old");
} finally {
store.state.selectedOrganization = org;
}
});
it("keeps Undo working when the message starts with the lowercase query", async () => {
const setMessage = (deletedQuery: string) =>
i18n.global.mergeLocaleMessage("en-us", { recentSearches: { deletedQuery } });
setMessage("{query} deleted");
try {
const wrapper = mountDrawer();
await flushPromises();
await deleteOld(wrapper);
const record = toastRecords.find((r) => r.open);
expect(record?.message).toBe("Query old deleted");
record?.action?.handler();
await flushPromises();
expect(rowShown(wrapper)).toBe(true);
vi.advanceTimersByTime(20000);
await flushPromises();
expect(api.remove).not.toHaveBeenCalled();
} finally {
setMessage("Deleted {query}");
}
});
it("closes the toast before committing when the drawer goes away", async () => {
const wrapper = mountDrawer();
await flushPromises();
await deleteOld(wrapper);
const record = deleteToast();
wrapper.unmount();
await flushPromises();
expect(api.remove).toHaveBeenCalledWith("default", "old");
expect(record?.open).toBe(false);
});
});
it("stars or unstars only the row clicked, by its own flag", async () => {
api.list.mockResolvedValue({
data: [
row("today", { query: "same", streams: ["k8s"] }),
row("other", { query: "same", streams: ["other"], starred: true }),
],
});
const wrapper = mountDrawer();
await flushPromises();
expect(wrapper.find('[data-test="recent-searches-star-today"]').attributes("aria-label")).toBe(
"Star",
);
await wrapper.find('[data-test="recent-searches-star-today"]').trigger("click");
await wrapper.find('[data-test="recent-searches-star-other"]').trigger("click");
await flushPromises();
expect(api.star.mock.calls).toEqual([
["default", "today", true],
["default", "other", false],
]);
});
it("says search or searches to match each count in the clear dialog", async () => {
api.count.mockImplementation((_org: string, params: { starred: boolean }) =>
Promise.resolve({ data: { count: params.starred ? 1 : 1 } }),
);
const wrapper = mountDrawer();
await flushPromises();
await wrapper.find('[data-test="recent-searches-clear-btn"]').trigger("click");
await flushPromises();
expect(wrapper.find('[data-test="recent-searches-clear-body"]').text()).toContain(
"your 1 recent Logs search in",
);
expect(
wrapper.find('[data-test="recent-searches-clear-include-starred"]').attributes("label"),
).toBe("Also remove my 1 starred search");
});
it("clears this page and keeps starred by default, all pages and starred on request", async () => {
api.count.mockImplementation((_org: string, params: { starred: boolean }) =>
Promise.resolve({ data: { count: params.starred ? 3 : 1234 } }),
);
const wrapper = mountDrawer();
await flushPromises();
await wrapper.find('[data-test="recent-searches-clear-btn"]').trigger("click");
await flushPromises();
expect(wrapper.find('[data-test="recent-searches-clear-body"]').text()).toContain(
"your 1234 recent Logs searches",
);
expect(api.count).toHaveBeenCalledWith("default", { source: "logs", starred: false });
expect(api.count).toHaveBeenCalledWith("default", { source: "logs", starred: true });
expect(api.list).not.toHaveBeenCalledWith("default", expect.objectContaining({ limit: 200 }));
await wrapper.find('[data-test="dialog-confirm"]').trigger("click");
await flushPromises();
expect(api.clear).toHaveBeenLastCalledWith("default", {
source: "logs",
include_starred: false,
});
await wrapper.find('[data-test="scope-all"]').trigger("click");
await wrapper.find('[data-test="recent-searches-clear-btn"]').trigger("click");
await flushPromises();
expect(wrapper.find('[data-test="recent-searches-clear-body"]').text()).toContain(
"across Logs, Traces and Metrics",
);
await wrapper.find('[data-test="recent-searches-clear-include-starred"]').setValue(true);
await wrapper.find('[data-test="dialog-confirm"]').trigger("click");
await flushPromises();
expect(api.clear).toHaveBeenLastCalledWith("default", {
source: "logs,traces,metrics",
include_starred: true,
});
expect(api.count).toHaveBeenCalledWith("default", {
source: "logs,traces,metrics",
starred: false,
});
});
it("puts scope and retention in the info tooltip, from /config or kept until deleted", async () => {
store.state.zoConfig.query_history_retention_days = 30;
const wrapper = mountDrawer();
await flushPromises();
expect(wrapper.text()).not.toContain("visible only to you");
expect(wrapper.find('[data-test="recent-searches-retention"]').exists()).toBe(false);
expect(wrapper.find('[data-test="recent-searches-about"]').attributes("aria-label")).toContain(
"Kept 30 days",
);
store.state.zoConfig.query_history_retention_days = 0;
await flushPromises();
expect(wrapper.find('[data-test="recent-searches-about"]').attributes("aria-label")).toContain(
"Kept until deleted",
);
});
});
describe("Starred only (real checkbox)", () => {
const ROWS = [row("today"), row("starred", { starred: true }), row("other")];
beforeEach(() => {
vi.clearAllMocks();
api.list.mockImplementation((_org: string, params: { starred?: boolean }) =>
Promise.resolve({ data: params.starred ? ROWS.filter((r) => r.starred) : ROWS }),
);
});
const mountWithRealCheckbox = () =>
mount(RecentSearchesDrawer, {
props: { source: "logs", open: true },
global: {
plugins: [store],
stubs: {
ODrawer: {
props: ["open"],
template: `<div v-if="open"><slot /><slot name="footer" /></div>`,
},
ODialog: true,
OTimeCell: true,
OTooltip: true,
},
},
});
const shownRows = (wrapper: ReturnType<typeof mountWithRealCheckbox>) =>
wrapper.findAll('[data-test^="recent-searches-drawer-row-"]').length;
const box = (wrapper: ReturnType<typeof mountWithRealCheckbox>) =>
wrapper.find('[data-test="recent-searches-starred-only"] [role="checkbox"]');
it("shows a checked box and only starred rows, also after the drawer is closed and reopened", async () => {
const wrapper = mountWithRealCheckbox();
await flushPromises();
expect(shownRows(wrapper)).toBe(3);
expect(box(wrapper).attributes("aria-checked")).toBe("false");
await box(wrapper).trigger("click");
await flushPromises();
expect(box(wrapper).attributes("aria-checked")).toBe("true");
expect(api.list).toHaveBeenLastCalledWith(
"default",
expect.objectContaining({ starred: true }),
);
expect(shownRows(wrapper)).toBe(1);
await wrapper.setProps({ open: false });
await wrapper.setProps({ open: true });
await flushPromises();
expect(box(wrapper).attributes("aria-checked")).toBe("true");
expect(shownRows(wrapper)).toBe(1);
await box(wrapper).trigger("click");
await flushPromises();
expect(box(wrapper).attributes("aria-checked")).toBe("false");
expect(shownRows(wrapper)).toBe(3);
});
it("shows a cut query's full multi-line text on hover, line breaks and indentation kept", async () => {
const query =
"SELECT level,\n count(*)\n FROM k8s_logs\n -- errors only\n WHERE level = 'error'";
api.list.mockResolvedValue({ data: [row("long", { query })] });
const wrapper = mount(RecentSearchesDrawer, {
props: { source: "logs", open: true },
global: {
plugins: [store],
stubs: {
ODrawer: { props: ["open"], template: `<div v-if="open"><slot /></div>` },
ODialog: true,
OTimeCell: true,
},
},
attachTo: document.body,
});
await flushPromises();
const cell = wrapper.find('[data-test="recent-searches-drawer-query"]').element;
Object.defineProperty(cell, "scrollWidth", { configurable: true, value: 1200 });
Object.defineProperty(cell, "clientWidth", { configurable: true, value: 300 });
vi.useFakeTimers();
try {
cell.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(700);
await nextTick();
await nextTick();
const bubble = document.body.querySelector('[data-test="o-tooltip-content"]');
expect(bubble?.textContent).toContain(query);
expect(bubble?.className).toContain("whitespace-pre-wrap");
expect(bubble?.className).toContain("font-mono");
} finally {
vi.useRealTimers();
wrapper.unmount();
}
});
it("shows a failed row's whole error on hover only when the line is cut", async () => {
const message = `Search field not found: ${"x".repeat(300)}`;
api.list.mockResolvedValue({
data: [row("bad", { status: "error", context: { error: message } })],
});
const wrapper = mount(RecentSearchesDrawer, {
props: { source: "logs", open: true },
global: {
plugins: [store],
stubs: {
ODrawer: { props: ["open"], template: `<div v-if="open"><slot /></div>` },
ODialog: true,
OTimeCell: true,
},
},
attachTo: document.body,
});
await flushPromises();
const line = wrapper.find('[data-test="recent-searches-drawer-error"]').element;
const hover = async () => {
line.dispatchEvent(new MouseEvent("mouseleave"));
await nextTick();
line.dispatchEvent(new MouseEvent("mouseenter"));
vi.advanceTimersByTime(700);
await nextTick();
await nextTick();
};
const bubble = () => document.body.querySelector('[data-test="o-tooltip-content"]');
const setWidths = (scrollWidth: number, clientWidth: number) => {
Object.defineProperty(line, "scrollWidth", { configurable: true, value: scrollWidth });
Object.defineProperty(line, "clientWidth", { configurable: true, value: clientWidth });
};
vi.useFakeTimers();
try {
setWidths(300, 300);
await hover();
expect(bubble()).toBeNull();
setWidths(1200, 300);
await hover();
expect(bubble()?.textContent).toContain(message);
} finally {
vi.useRealTimers();
wrapper.unmount();
}
});
});
@@ -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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
import store from "@/test/unit/helpers/store";
import type { QueryHistoryEntry } from "@/services/query_history";
const api = vi.hoisted(() => ({
list: vi.fn(),
record: vi.fn(),
star: vi.fn(),
remove: vi.fn(),
clear: vi.fn(),
count: vi.fn(),
}));
vi.mock("@/services/query_history", () => ({ default: api }));
vi.mock("vue-router", () => ({ useRouter: () => ({ push: vi.fn(), resolve: vi.fn() }) }));
import i18n from "@/locales";
import RecentSearchesDrawer from "./RecentSearchesDrawer.vue";
enableAutoUnmount(afterEach);
const at = (iso: string) => Date.parse(iso) * 1000;
const row = (id: string, ranAt: string): QueryHistoryEntry => ({
id,
source: "logs",
query: `query ${id}`,
query_key: id,
streams: ["k8s"],
context: {},
status: "ok",
starred: false,
run_count: 1,
created_at: at(ranAt),
updated_at: at(ranAt),
});
const mountDrawer = () =>
mount(RecentSearchesDrawer, {
props: { source: "logs", open: true },
global: {
plugins: [store, i18n],
stubs: {
ODrawer: {
props: ["open"],
template: `<div v-if="open"><slot name="header-left" /><slot /><slot name="footer" /></div>`,
},
RecentSearchesDayPicker: {
props: ["modelValue"],
emits: ["update:modelValue"],
template: `<input data-stub="date" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`,
},
OTimeCell: true,
OTooltip: true,
},
},
});
describe("RecentSearchesDrawer in the configured timezone", () => {
const original = store.state.timezone;
beforeEach(() => {
vi.clearAllMocks();
store.state.timezone = "Asia/Kolkata";
api.count.mockResolvedValue({ data: { count: 0 } });
// 20:00 UTC is 01:30 the next day in Kolkata; the test runner's own zone is UTC.
api.list.mockResolvedValue({
data: [row("late", "2025-03-04T20:00:00Z"), row("noon", "2025-03-04T10:00:00Z")],
});
});
afterEach(() => {
store.state.timezone = original;
});
it("cuts the day headers in the app timezone, not the browser's", async () => {
const wrapper = mountDrawer();
await flushPromises();
const days = wrapper.findAll('[data-test="recent-searches-day"]');
expect(days).toHaveLength(2);
expect(days[0].text()).toContain("Wed, Mar 5");
expect(days[1].text()).toContain("Tue, Mar 4");
});
it("asks for a Ran on day's bounds in the app timezone", async () => {
const wrapper = mountDrawer();
await flushPromises();
await wrapper.find('[data-stub="date"]').setValue("2025-03-05");
await flushPromises();
expect(api.list.mock.calls.at(-1)?.[1]).toMatchObject({
ran_from: at("2025-03-04T18:30:00Z"),
ran_to: at("2025-03-05T18:30:00Z") - 1,
});
});
});
@@ -0,0 +1,653 @@
<!-- 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/>.
-->
<template>
<ODrawer
v-model:open="open"
:size="isMobile ? 'full' : 'md'"
:title="t('recentSearches.title')"
bleed
data-test="recent-searches-drawer"
>
<template #header-left>
<OButton
variant="ghost"
size="icon-xs"
icon-left="info-outline"
:aria-label="aboutText"
data-test="recent-searches-about"
>
<OTooltip :content="aboutText" />
</OButton>
</template>
<div class="flex h-full min-h-0 flex-col">
<div class="border-border-default flex shrink-0 flex-col gap-2 border-b px-4 py-2">
<div class="flex items-center gap-2">
<OSearchInput
v-model="search"
size="sm"
clearable
:debounce="300"
:placeholder="t('recentSearches.searchPlaceholder')"
class="min-w-0 flex-1"
data-test="recent-searches-search"
/>
<OToggleGroup v-model="scope" type="single" data-test="recent-searches-scope">
<OToggleGroupItem value="page" size="sm" data-test="recent-searches-scope-page">
{{ t(`recentSearches.page.${source}`) }}
</OToggleGroupItem>
<OToggleGroupItem value="all" size="sm" data-test="recent-searches-scope-all">
{{ t("recentSearches.allPages") }}
</OToggleGroupItem>
</OToggleGroup>
</div>
<div class="flex flex-wrap items-center gap-2">
<OSelect
v-model="stream"
:options="streamOptions"
size="sm"
clearable
searchable
creatable
:placeholder="t('recentSearches.allStreams')"
class="w-44"
data-test="recent-searches-stream"
/>
<OSelect
v-model="status"
:options="statusOptions"
size="sm"
class="w-32"
data-test="recent-searches-status"
/>
<RecentSearchesDayPicker
v-model="ranOn"
:placeholder="t('recentSearches.datePlaceholder')"
:field-label="t('recentSearches.filterByDateRun')"
:zone="zone"
data-test="recent-searches-ran-on"
/>
<OCheckbox
v-model="starredOnly"
size="sm"
:label="t('recentSearches.starredOnly')"
data-test="recent-searches-starred-only"
/>
<OButton
v-if="filtered"
variant="ghost-primary"
size="xs"
icon-left="restart-alt"
class="ms-auto"
data-test="recent-searches-reset-filters"
@click="resetFilters"
>
{{ t("recentSearches.reset") }}
<OTooltip :content="t('recentSearches.resetFilters')" />
</OButton>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto" data-test="recent-searches-drawer-list">
<div v-if="history.isPending.value && open" class="flex flex-col gap-3 p-4">
<OSkeleton v-for="n in 5" :key="n" type="text" />
</div>
<OEmptyState
v-else-if="history.isError.value"
size="inline"
icon="error"
:title="t('recentSearches.loadError')"
:action-label="t('recentSearches.retry')"
action-icon="refresh"
@action="history.refetch()"
/>
<OEmptyState
v-else-if="!rows.length"
size="inline"
icon="history"
:title="filtered ? t('recentSearches.noMatch') : t('recentSearches.emptyTitle')"
:action-label="filtered ? t('recentSearches.resetFilters') : undefined"
action-icon="restart-alt"
data-test="recent-searches-drawer-empty"
@action="resetFilters"
/>
<section v-for="group in groups" :key="group.dayMs" :data-test="`recent-searches-day`">
<h3
class="bg-surface-panel text-text-secondary border-border-subtle sticky top-0 z-1 border-b px-4 py-1 text-xs font-semibold uppercase"
>
{{ dayLabel(group) }}
</h3>
<div
v-for="entry in group.entries"
:key="entry.id"
class="border-border-subtle hover:bg-dropdown-item-hover-bg group flex cursor-pointer flex-col gap-1 border-b px-4 py-2"
:data-test="`recent-searches-drawer-row-${entry.id}`"
@click="restore(entry, restoreTimeOf(entry), true)"
>
<div class="flex min-w-0 items-center gap-2">
<OTag
v-if="scope === 'all'"
:label="t(`recentSearches.page.${entry.source}`)"
size="sm"
variant="primary-outline"
class="shrink-0"
/>
<OTag
v-for="name in entry.streams.slice(0, 3)"
:key="name"
:label="raw(name)"
size="sm"
class="shrink-0"
/>
<span v-if="entry.streams.length > 3" class="text-text-secondary text-xs">
+{{ entry.streams.length - 3 }}
</span>
<span class="ms-auto flex shrink-0 items-center gap-1">
<OTag
v-if="entry.status === 'error'"
:label="t('recentSearches.failed')"
variant="error-soft"
size="sm"
data-test="recent-searches-failed-tag"
/>
<span
v-else-if="entry.context.warning !== undefined"
class="bg-status-warning-text size-2 rounded-full"
data-test="recent-searches-drawer-warning"
>
<OTooltip :content="t('recentSearches.warning')" />
</span>
<OButton
variant="ghost"
size="icon-sm"
:icon-left="entry.starred ? 'star' : 'star-outline'"
:class="entry.starred ? 'text-accent' : ''"
:aria-label="
entry.starred ? t('recentSearches.unstar') : t('recentSearches.star')
"
:data-test="`recent-searches-star-${entry.id}`"
@click.stop="toggleStar(entry)"
>
<OTooltip
:content="entry.starred ? t('recentSearches.unstar') : t('recentSearches.star')"
/>
</OButton>
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
variant="ghost"
size="icon-sm"
icon-left="more-vert"
:aria-label="t('common.more')"
:data-test="`recent-searches-more-${entry.id}`"
@click.stop
/>
</template>
<ODropdownItem
icon-left="edit-note"
:data-test="`recent-searches-load-${entry.id}`"
@select="restore(entry, restoreTimeOf(entry), false)"
>
<span>{{ t("recentSearches.loadOnly") }}</span>
</ODropdownItem>
<ODropdownItem
icon-left="link"
:data-test="`recent-searches-copy-link-${entry.id}`"
@select="copyLink(entry)"
>
<span>{{ t("recentSearches.copyLink") }}</span>
</ODropdownItem>
<ODropdownItem
icon-left="content-copy"
:data-test="`recent-searches-copy-query-${entry.id}`"
@select="copyQuery(entry)"
>
<span>{{ t("recentSearches.copyQuery") }}</span>
</ODropdownItem>
<ODropdownItem
v-if="functionOf(entry)"
icon-left="content-copy"
:data-test="`recent-searches-copy-function-${entry.id}`"
@select="copyFunction(entry)"
>
<span>{{ t("recentSearches.copyFunction") }}</span>
</ODropdownItem>
<ODropdownItem
variant="destructive"
icon-left="delete"
:data-test="`recent-searches-delete-${entry.id}`"
@select="removeWithUndo(entry)"
>
<span>{{ t("common.delete") }}</span>
</ODropdownItem>
</ODropdown>
</span>
</div>
<OTruncatedText
:lines="2"
:tooltip="queryTooltip(entry)"
tooltip-class="whitespace-pre-wrap font-mono"
class="text-text-heading font-mono text-xs break-all"
data-test="recent-searches-drawer-query"
>
{{ entry.query }}
</OTruncatedText>
<OTruncatedText
v-if="functionOf(entry)"
:tooltip="raw(functionOf(entry))"
tooltip-class="whitespace-pre-wrap font-mono"
class="text-text-secondary font-mono text-xs"
data-test="recent-searches-drawer-function"
>
{{ t("recentSearches.functionLabel") }}: {{ functionOf(entry) }}
</OTruncatedText>
<OTruncatedText
v-if="entry.status === 'error' && entry.context.error"
:tooltip="raw(entry.context.error)"
class="text-status-error-text text-xs"
data-test="recent-searches-drawer-error"
>
{{ entry.context.error }}
</OTruncatedText>
<div class="text-text-secondary flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span v-if="windowText(entry)" data-test="recent-searches-window">
<OIcon name="schedule" size="xs" class="me-1 align-middle" />{{ windowText(entry) }}
</span>
<span data-test="recent-searches-span">{{ spanText(entry) }}</span>
<span class="ms-auto flex items-center gap-1">
<OButton
variant="outline"
size="xs"
icon-left="play-arrow"
:data-test="`recent-searches-rerun-now-${entry.id}`"
@click.stop="restore(entry, 'now', true)"
>
{{ t("recentSearches.rerunNow") }}
</OButton>
<OButton
v-if="entry.context.time || entry.context.time_range"
variant="outline"
size="xs"
icon-left="history"
:data-test="`recent-searches-rerun-original-${entry.id}`"
@click.stop="restore(entry, 'original', true)"
>
{{ t("recentSearches.rerunOriginal") }}
</OButton>
</span>
</div>
</div>
</section>
<div v-if="history.hasNextPage.value" class="flex justify-center p-3">
<OButton
variant="outline"
size="sm"
:loading="history.isFetchingNextPage.value"
data-test="recent-searches-load-more"
@click="history.fetchNextPage()"
>
{{ t("recentSearches.loadMore") }}
</OButton>
</div>
</div>
</div>
<template #footer>
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-end">
<OButton
variant="outline-destructive"
size="sm"
icon-left="delete"
data-test="recent-searches-clear-btn"
@click="clearOpen = true"
>
{{ t("recentSearches.clear") }}
</OButton>
</div>
</div>
</template>
</ODrawer>
<ODialog
v-model:open="clearOpen"
size="sm"
:title="t('recentSearches.clear')"
:secondary-button-label="t('common.cancel')"
:primary-button-label="t('recentSearches.confirmClear')"
primary-button-variant="destructive"
:primary-button-loading="clearing"
primary-button-data-test="recent-searches-clear-confirm"
data-test="recent-searches-clear-dialog"
@click:secondary="clearOpen = false"
@click:primary="confirmClear"
>
<div class="flex flex-col gap-3">
<p data-test="recent-searches-clear-body">
{{
scope === "page"
? t(
"recentSearches.clearBodyPage",
{
count: countLabel(unstarredCount.data.value),
page: t(`recentSearches.page.${source}`),
org: orgId,
},
pluralOf(unstarredCount.data.value),
)
: t(
"recentSearches.clearBodyAll",
{ count: countLabel(unstarredCount.data.value), org: orgId },
pluralOf(unstarredCount.data.value),
)
}}
</p>
<OCheckbox
v-model="includeStarred"
:label="
t(
'recentSearches.includeStarred',
{ count: countLabel(starredCount.data.value) },
pluralOf(starredCount.data.value),
)
"
data-test="recent-searches-clear-include-starred"
/>
</div>
</ODialog>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { DateTime } from "luxon";
import { useInfiniteQuery, useQuery } from "@tanstack/vue-query";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import useBreakpoint from "@/composables/useBreakpoint";
import { useRecentSearches } from "@/composables/queryHistory/useRecentSearches";
import { queryHistoryCountQuery, queryHistoryQuery } from "@/services/query_history.queries";
import type {
HistorySource,
HistoryStatus,
QueryHistoryEntry,
QueryHistoryListParams,
} from "@/services/query_history";
import {
absoluteLink,
dayBoundsUs,
HISTORY_SOURCES,
defaultRestoreTime,
groupByDay,
type DayGroup,
type RestoreTime,
} from "@/utils/queryHistory/recentSearches";
import { recentSearchRoute } from "@/utils/queryHistory/routes";
import { logsEntryFunction } from "@/utils/queryHistory/logs";
import { copyToClipboard } from "@/utils/clipboard";
import { toast, toastRecords } from "@/lib/feedback/Toast/useToast";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import RecentSearchesDayPicker from "./RecentSearchesDayPicker.vue";
import { useRecentEntryText } from "@/composables/queryHistory/useRecentEntryText";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OCheckbox from "@/lib/forms/Checkbox/OCheckbox.vue";
const props = defineProps<{ source: HistorySource }>();
const UNDO_DELETE_MS = 6000;
const emit = defineEmits<{
restore: [entry: QueryHistoryEntry, when: RestoreTime, run: boolean];
}>();
const open = defineModel<boolean>("open", { default: false });
const { t } = useI18nTyped();
const router = useRouter();
const { isMobile } = useBreakpoint();
const { orgId, about: aboutText, star, remove, clear } = useRecentSearches();
const { zone, windowText, spanText } = useRecentEntryText();
const search = ref("");
const scope = ref<"page" | "all">("page");
const stream = ref<string | null>(null);
const status = ref<"all" | HistoryStatus>("all");
const ranOn = ref<string | undefined>(undefined);
const starredOnly = ref(false);
const clearOpen = ref(false);
const includeStarred = ref(false);
const clearing = ref(false);
const sourceParam = computed(() =>
scope.value === "page" ? props.source : HISTORY_SOURCES.join(","),
);
const filters = computed<QueryHistoryListParams>(() => ({
source: sourceParam.value,
...(search.value.trim() ? { q: search.value.trim() } : {}),
...(stream.value ? { stream: stream.value } : {}),
...(status.value !== "all" ? { status: status.value } : {}),
...(starredOnly.value ? { starred: true } : {}),
...(ranOn.value ? (dayBoundsUs(ranOn.value, zone.value) ?? {}) : {}),
}));
const filtered = computed(
() =>
!!search.value.trim() ||
!!stream.value ||
status.value !== "all" ||
starredOnly.value ||
!!ranOn.value,
);
const resetFilters = () => {
search.value = "";
stream.value = null;
status.value = "all";
ranOn.value = undefined;
starredOnly.value = false;
};
const history = useInfiniteQuery(() =>
Object.assign(queryHistoryQuery(orgId.value, filters.value), {
enabled: open.value && !!orgId.value,
}),
);
const hiddenIds = ref<Set<string>>(new Set());
const pendingDeletes = new Map<string, { org: string; ids: Set<string>; dismiss: () => void }>();
const rows = computed(() =>
(history.data.value?.pages.flat() ?? []).filter((entry) => !hiddenIds.value.has(entry.id)),
);
const groups = computed(() => groupByDay(rows.value, Date.now(), zone.value));
const unstarredCount = useQuery(() =>
Object.assign(queryHistoryCountQuery(orgId.value, sourceParam.value, false), {
enabled: clearOpen.value && !!orgId.value,
}),
);
const starredCount = useQuery(() =>
Object.assign(queryHistoryCountQuery(orgId.value, sourceParam.value, true), {
enabled: clearOpen.value && !!orgId.value,
}),
);
const streamOptions = computed(() => {
const names = new Set(rows.value.flatMap((entry) => entry.streams));
if (stream.value) names.add(stream.value);
return [...names].sort().map((name) => ({ label: raw(name), value: name }));
});
const statusOptions = computed(() => [
{ label: t("recentSearches.statusAll"), value: "all" },
{ label: t("recentSearches.statusOk"), value: "ok" },
{ label: t("recentSearches.statusError"), value: "error" },
]);
const toggleStar = (entry: QueryHistoryEntry) => star(entry.id, !entry.starred);
// Decoded once per loaded entry, not on every use in the row.
const functions = computed(
() => new Map(rows.value.map((entry) => [entry.id, logsEntryFunction(entry)])),
);
const functionOf = (entry: QueryHistoryEntry) => functions.value.get(entry.id) ?? "";
const dayLabel = (group: DayGroup): I18nText => {
if (group.daysAgo === 0) return t("recentSearches.today");
if (group.daysAgo === 1) return t("recentSearches.yesterday");
return raw(
DateTime.fromMillis(group.dayMs, zone.value ? { zone: zone.value } : {}).toFormat("ccc, LLL d"),
);
};
const restoreTimeOf = (entry: QueryHistoryEntry) =>
defaultRestoreTime(entry, Date.now(), zone.value);
const countLabel = (count: number | undefined) => (count === undefined ? "…" : String(count));
// A count still loading reads as plural ("… searches").
const pluralOf = (count: number | undefined) => count ?? 2;
const restore = (entry: QueryHistoryEntry, when: RestoreTime, run: boolean) => {
emit("restore", entry, when, run);
open.value = false;
};
const copyLink = (entry: QueryHistoryEntry) => {
const route = recentSearchRoute(entry, "original", orgId.value, "link");
if (!route) return;
copyToClipboard(absoluteLink(router.resolve(route).href), t, {
successMessage: t("recentSearches.linkCopied"),
});
};
const setHidden = (id: string, hidden: boolean) => {
const next = new Set(hiddenIds.value);
if (hidden) next.add(id);
else next.delete(id);
hiddenIds.value = next;
};
const shortQuery = (query: string) => {
const flat = query.replace(/\s+/g, " ").trim();
return flat.length > 60 ? `${flat.slice(0, 60)}…` : flat;
};
// The backend has no restore: a row stays hidden while its toast offers Undo, and is deleted once that toast closes.
const removeWithUndo = (entry: QueryHistoryEntry) => {
setHidden(entry.id, true);
const org = orgId.value;
let toastId = "";
const dismissToast = toast({
variant: "success",
message: t("recentSearches.deletedQuery", { query: shortQuery(entry.query) }),
timeout: UNDO_DELETE_MS,
action: {
label: t("recentSearches.undo"),
handler: () => {
const pending = pendingDeletes.get(toastId);
if (!pending) return;
pendingDeletes.delete(toastId);
pending.ids.forEach((id) => setHidden(id, false));
dismissToast();
},
},
});
toastId = dismissToast.id;
const pending = pendingDeletes.get(toastId) ?? {
org,
ids: new Set<string>(),
dismiss: dismissToast,
};
pending.ids.add(entry.id);
pendingDeletes.set(toastId, pending);
commitClosedDeletes();
};
const commitDeletes = (toastId: string) => {
const pending = pendingDeletes.get(toastId);
if (!pending) return;
pendingDeletes.delete(toastId);
// A delete the server refused shows its row again, since the entry is still stored.
pending.ids.forEach((id) => remove(id, pending.org).catch(() => setHidden(id, false)));
};
const commitClosedDeletes = () => {
for (const toastId of [...pendingDeletes.keys()]) {
if (!toastRecords.some((r) => r.id === toastId && r.open)) commitDeletes(toastId);
}
};
watch(toastRecords, commitClosedDeletes, { deep: true });
// Leaving commits the deletes, so their Undo must go first.
onBeforeUnmount(() => {
for (const [toastId, pending] of [...pendingDeletes]) {
pending.dismiss();
commitDeletes(toastId);
}
});
const copyQuery = (entry: QueryHistoryEntry) =>
copyToClipboard(entry.query, t, { successMessage: t("recentSearches.queryCopied") });
const copyFunction = (entry: QueryHistoryEntry) =>
copyToClipboard(functionOf(entry), t, {
successMessage: t("recentSearches.functionCopied"),
});
// A cut query's tooltip carries the VRL function too, since the search does not reproduce without it.
const queryTooltip = (entry: QueryHistoryEntry): I18nText | undefined => {
const vrl = functionOf(entry);
if (!vrl) return undefined;
return raw(`${entry.query}\n\n${t("recentSearches.functionLabel")}:\n${vrl}`);
};
const confirmClear = async () => {
clearing.value = true;
try {
await clear({
source: sourceParam.value,
include_starred: includeStarred.value,
});
clearOpen.value = false;
} catch {
// The mutation's error toast already told the user.
} finally {
clearing.value = false;
}
};
watch(clearOpen, (isOpen) => {
if (isOpen) includeStarred.value = false;
});
const show = (initialStatus: HistoryStatus | null = null) => {
status.value = initialStatus ?? "all";
open.value = true;
};
defineExpose({ show });
</script>
@@ -0,0 +1,210 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
import { defineComponent, h, nextTick, ref } from "vue";
import store from "@/test/unit/helpers/store";
import i18n from "@/locales";
import {
routeEditorKeyToRecents,
type RecentsKeyTarget,
} from "@/utils/queryHistory/recentSearches";
const api = vi.hoisted(() => ({ list: vi.fn(), record: vi.fn(), star: vi.fn(), remove: vi.fn() }));
vi.mock("@/services/query_history", () => ({ default: api }));
vi.mock("@/composables/useChatHistory", () => ({
useChatHistory: vi.fn(() => ({
saveToHistory: vi.fn().mockResolvedValue(1),
loadHistory: vi.fn().mockResolvedValue([]),
loadChat: vi.fn().mockResolvedValue(null),
deleteChatById: vi.fn().mockResolvedValue(true),
clearAllHistory: vi.fn().mockResolvedValue(true),
updateChatTitle: vi.fn().mockResolvedValue(true),
})),
}));
import QueryEditor from "@/components/QueryEditor.vue";
import RecentSearchesFocusDropdown from "./RecentSearchesFocusDropdown.vue";
import { placeCaret } from "@/test/unit/helpers/monacoCaret";
enableAutoUnmount(afterEach);
const NOW_US = Date.now() * 1000;
const generateSql = vi.fn().mockResolvedValue(undefined);
// Stands in for Monaco: the same `.monaco-editor` surface and caret element the real editor renders.
const MonacoStub = defineComponent({
setup(_, { expose }) {
expose({ handleGenerateSQL: generateSql, getValue: () => "" });
return () =>
h("div", { class: "monaco-editor" }, [
h("textarea", { class: "inputarea", "data-test": "monaco-input" }),
h("div", { class: "cursors-layer" }, [
h("div", { class: "cursor", ref: (el) => placeCaret(el as Element | null) }),
]),
]);
},
});
const mountEditorBox = async () => {
const open = ref(false);
const box = ref<HTMLElement | null>(null);
const dropdown = ref<RecentsKeyTarget | null>(null);
const emitted: string[] = [];
const Host = defineComponent({
setup() {
return () =>
h("div", [
h(
"div",
{
ref: box,
onKeydownCapture: (e: KeyboardEvent) =>
open.value &&
routeEditorKeyToRecents(e, dropdown.value, () => (open.value = false)),
},
[
h(QueryEditor, {
query: "",
nlpMode: true,
editorHeight: "200px",
dataTestPrefix: "logs-search-bar",
}),
],
),
h(RecentSearchesFocusDropdown, {
ref: dropdown,
source: "logs",
anchor: box.value,
open: open.value,
"onUpdate:open": (value: boolean) => (open.value = value),
onRun: () => emitted.push("run"),
onLoad: () => emitted.push("load"),
}),
]);
},
});
mount(Host, {
attachTo: document.body,
global: { plugins: [store, i18n], stubs: { CodeQueryEditor: MonacoStub, OTimeCell: true } },
});
await flushPromises();
open.value = true;
await vi.waitFor(() =>
expect(document.querySelector('[data-test="recent-searches-row-e0"]')).not.toBeNull(),
);
const aiInput = document.querySelector<HTMLInputElement>(
'[data-test^="logs-search-bar-ai-input-field"] input',
)!;
const monacoInput = document.querySelector<HTMLElement>('[data-test="monaco-input"]')!;
return { open, emitted, aiInput, monacoInput };
};
const keydown = (target: Element, key: string, shiftKey = false) => {
const event = new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true });
target.dispatchEvent(event);
return event;
};
describe("recent-searches dropdown and the controls that share the editor box", () => {
beforeEach(() => {
vi.clearAllMocks();
store.state.zoConfig.query_history_enabled = true;
api.list.mockResolvedValue({
data: [
{
id: "e0",
source: "logs",
query: "level='error'",
query_key: "k",
streams: ["k8s"],
context: {},
status: "ok",
starred: false,
run_count: 1,
created_at: NOW_US,
updated_at: NOW_US,
},
],
});
});
afterEach(() => {
delete store.state.zoConfig.query_history_enabled;
document.body.innerHTML = "";
});
it("leaves Enter, Shift+Enter and ArrowDown in the AI prompt to the prompt while the dropdown is open", async () => {
const { open, emitted, aiInput } = await mountEditorBox();
expect(aiInput).not.toBeNull();
aiInput.value = "show errors";
aiInput.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
const arrow = keydown(aiInput, "ArrowDown");
const shiftEnter = keydown(aiInput, "Enter", true);
const enter = keydown(aiInput, "Enter");
await flushPromises();
expect(arrow.defaultPrevented).toBe(false);
expect(shiftEnter.defaultPrevented).toBe(false);
expect(enter.defaultPrevented).toBe(false);
expect(document.activeElement?.getAttribute("data-test") ?? "").not.toMatch(
/^recent-searches-row/,
);
expect(emitted).toEqual([]);
expect(generateSql).toHaveBeenCalledWith("show errors", expect.anything(), expect.anything());
expect(open.value).toBe(true);
});
it("leaves Enter to the editor's newline until a row is hovered, then runs that row", async () => {
const { emitted, monacoInput } = await mountEditorBox();
const plain = keydown(monacoInput, "Enter");
await flushPromises();
expect(plain.defaultPrevented).toBe(false);
expect(emitted).toEqual([]);
document
.querySelector('[data-test="recent-searches-row-e0"]')!
.dispatchEvent(new PointerEvent("pointerenter"));
await flushPromises();
const enter = keydown(monacoInput, "Enter");
await flushPromises();
expect(enter.defaultPrevented).toBe(true);
expect(emitted).toEqual(["run"]);
});
it("closes when focus or a click moves to the AI prompt or a button in the same box", async () => {
const first = await mountEditorBox();
first.aiInput.focus();
await nextTick();
expect(first.open.value).toBe(false);
document.body.innerHTML = "";
const second = await mountEditorBox();
const send = document.querySelector('[data-test="logs-search-bar-ai-send-btn"]')!;
send.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
await nextTick();
expect(second.open.value).toBe(false);
});
it("stays open on a click or focus inside the editor's own surface", async () => {
const { open, monacoInput } = await mountEditorBox();
monacoInput.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
monacoInput.focus();
await nextTick();
expect(open.value).toBe(true);
});
});
@@ -0,0 +1,376 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { enableAutoUnmount, mount } from "@vue/test-utils";
import { defineComponent, h, nextTick } from "vue";
import store from "@/test/unit/helpers/store";
import { FIRST_LINE_CARET, type CaretBox } from "@/test/unit/helpers/monacoCaret";
const api = vi.hoisted(() => ({ list: vi.fn(), record: vi.fn(), star: vi.fn(), remove: vi.fn() }));
vi.mock("@/services/query_history", () => ({ default: api }));
import RecentSearchesFocusDropdown from "./RecentSearchesFocusDropdown.vue";
enableAutoUnmount(afterEach);
const rectOf =
({ left, top, right, bottom }: CaretBox) =>
() =>
({
left,
top,
right,
bottom,
x: left,
y: top,
width: right - left,
height: bottom - top,
toJSON: () => ({}),
}) as DOMRect;
// The editor box with Monaco's caret element inside, as the real editor renders it.
const editorWithCaret = (caret: CaretBox) => {
const anchor = document.createElement("div");
const monaco = document.createElement("div");
monaco.className = "monaco-editor";
const layer = document.createElement("div");
layer.className = "cursors-layer";
const cursor = document.createElement("div");
cursor.className = "cursor";
cursor.getBoundingClientRect = rectOf(caret);
layer.appendChild(cursor);
monaco.appendChild(layer);
anchor.appendChild(monaco);
document.body.appendChild(anchor);
// Monaco moves its caret by rewriting the element's inline style, which is what the panel listens for.
const moveCaret = (to: CaretBox) => {
cursor.getBoundingClientRect = rectOf(to);
cursor.style.top = `${to.top}px`;
cursor.style.left = `${to.left}px`;
};
// A page scroll or a resize moves the caret on screen with no change inside the editor.
const shiftCaret = (to: CaretBox) => (cursor.getBoundingClientRect = rectOf(to));
return { anchor, monaco, moveCaret, shiftCaret };
};
const setViewport = (width: number, height: number) => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
Object.defineProperty(window, "innerHeight", { configurable: true, value: height });
};
const panel = () =>
document.querySelector<HTMLElement>('[data-test="recent-searches-focus-dropdown"]');
const px = (value: string) => parseFloat(value);
const ownsNode = vi.fn((_node: Node | null) => false);
const PopoverStub = defineComponent({
setup(_, { expose }) {
expose({
ownsNode,
focusFirst: () => false,
pickActive: () => false,
scrollDetails: () => false,
});
return () => h("div", { "data-test": "popover-stub" });
},
});
const mountDropdown = (anchor: HTMLElement | null) =>
mount(RecentSearchesFocusDropdown, {
props: { source: "logs", anchor, open: true },
attachTo: document.body,
global: { plugins: [store], stubs: { RecentSearchesPopover: PopoverStub } },
});
describe("RecentSearchesFocusDropdown placement at the caret", () => {
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
beforeEach(() => {
store.state.zoConfig.query_history_enabled = true;
setViewport(1440, 900);
});
afterEach(() => {
vi.restoreAllMocks();
delete store.state.zoConfig.query_history_enabled;
setViewport(1024, 768);
document.body.innerHTML = "";
});
it("opens under the caret line from the caret's x, like the editor's suggest widget", () => {
mountDropdown(editorWithCaret(FIRST_LINE_CARET).anchor);
const el = panel()!;
expect(el.dataset.side).toBe("below");
expect(px(el.style.top)).toBe(FIRST_LINE_CARET.bottom);
expect(px(el.style.left)).toBe(FIRST_LINE_CARET.left);
expect(el.style.bottom).toBe("");
expect(px(el.style.maxHeight)).toBe(900 - FIRST_LINE_CARET.bottom - 8);
});
it("shifts left to stay on-screen when the caret is near the right edge", async () => {
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(430);
mountDropdown(editorWithCaret({ left: 1300, top: 91, right: 1302, bottom: 109 }).anchor);
window.dispatchEvent(new Event("resize"));
await nextTick();
expect(px(panel()!.style.left)).toBe(1440 - 430 - 8);
expect(px(panel()!.style.top)).toBe(109);
});
it("flips above the caret line only when there is no room below, never covering it", () => {
const caret = { left: 200, top: 800, right: 202, bottom: 818 };
mountDropdown(editorWithCaret(caret).anchor);
const el = panel()!;
expect(el.dataset.side).toBe("above");
expect(px(el.style.bottom)).toBe(900 - caret.top);
expect(el.style.top).toBe("");
expect(px(el.style.left)).toBe(caret.left);
expect(px(el.style.maxHeight)).toBe(caret.top - 8);
});
it("stays below with the least room it needs, rather than flipping", () => {
const caret = { left: 200, top: 714, right: 202, bottom: 732 };
mountDropdown(editorWithCaret(caret).anchor);
expect(panel()!.dataset.side).toBe("below");
expect(px(panel()!.style.maxHeight)).toBe(160);
});
it("stays closed when there is room neither below nor above the caret line", async () => {
setViewport(1440, 200);
const wrapper = mountDropdown(
editorWithCaret({ left: 200, top: 90, right: 202, bottom: 108 }).anchor,
);
await nextTick();
expect(panel()).toBeNull();
expect(wrapper.emitted("update:open")?.at(-1)).toEqual([false]);
});
it("stays closed while the editor has no caret on screen", async () => {
const anchor = document.createElement("div");
document.body.appendChild(anchor);
const wrapper = mountDropdown(anchor);
await nextTick();
expect(panel()).toBeNull();
expect(wrapper.emitted("update:open")?.at(-1)).toEqual([false]);
});
it("follows the caret as Monaco moves it, without polling", async () => {
const { anchor, moveCaret } = editorWithCaret(FIRST_LINE_CARET);
mountDropdown(anchor);
moveCaret({ left: 420, top: 127, right: 422, bottom: 145 });
await settle();
await nextTick();
expect(px(panel()!.style.top)).toBe(145);
expect(px(panel()!.style.left)).toBe(420);
});
it("follows the editor when its page scrolls, and ignores scrolling elsewhere", async () => {
const { anchor, shiftCaret } = editorWithCaret(FIRST_LINE_CARET);
const page = document.createElement("div");
page.appendChild(anchor);
document.body.appendChild(page);
const elsewhere = document.createElement("div");
document.body.appendChild(elsewhere);
mountDropdown(anchor);
await settle();
shiftCaret({ left: 113, top: 300, right: 115, bottom: 318 });
elsewhere.dispatchEvent(new Event("scroll"));
await settle();
await nextTick();
expect(px(panel()!.style.top)).toBe(109);
page.dispatchEvent(new Event("scroll"));
await nextTick();
expect(px(panel()!.style.top)).toBe(318);
});
it("follows the editor when a banner mounts above it inside a container that keeps its size", async () => {
const { anchor, shiftCaret } = editorWithCaret(FIRST_LINE_CARET);
const column = document.createElement("div");
column.appendChild(anchor);
document.body.appendChild(column);
mountDropdown(anchor);
await settle();
shiftCaret({ left: 113, top: 141, right: 115, bottom: 159 });
column.insertBefore(document.createElement("div"), anchor);
await settle();
expect(px(panel()!.style.top)).toBe(159);
});
it("re-places on a synthetic window resize, as Metrics dispatches one after an edit", async () => {
const { anchor, shiftCaret } = editorWithCaret(FIRST_LINE_CARET);
const wrapper = mountDropdown(anchor);
shiftCaret({ left: 461, top: 649, right: 463, bottom: 667 });
window.dispatchEvent(new Event("resize"));
await nextTick();
expect(px(panel()!.style.top)).toBe(667);
expect(px(panel()!.style.left)).toBe(461);
expect(wrapper.emitted("update:open")).toBeUndefined();
});
it.each([
["above the viewport", { left: 113, top: -60, right: 115, bottom: -42 }],
["at the viewport top edge", { left: 113, top: -18, right: 115, bottom: 0 }],
["below the viewport", { left: 113, top: 900, right: 115, bottom: 918 }],
["far below the viewport", { left: 113, top: 1400, right: 115, bottom: 1418 }],
])("never opens off-screen with the caret line %s", async (_where, caret) => {
const wrapper = mountDropdown(editorWithCaret(caret).anchor);
await nextTick();
expect(panel()).toBeNull();
expect(wrapper.emitted("update:open")?.at(-1)).toEqual([false]);
});
it.each([
["above", { left: 113, top: -60, right: 115, bottom: -42 }],
["below", { left: 113, top: 1000, right: 115, bottom: 1018 }],
])("closes when the page scrolls the caret line %s the viewport", async (_where, caret) => {
const { anchor, moveCaret } = editorWithCaret(FIRST_LINE_CARET);
const wrapper = mountDropdown(anchor);
expect(panel()).not.toBeNull();
moveCaret(caret);
await settle();
await nextTick();
expect(wrapper.emitted("update:open")?.at(-1)).toEqual([false]);
});
it("closes once the caret scrolls out of reach", async () => {
const { anchor, moveCaret } = editorWithCaret(FIRST_LINE_CARET);
const wrapper = mountDropdown(anchor);
setViewport(1440, 300);
moveCaret({ left: 113, top: 130, right: 115, bottom: 148 });
await settle();
await nextTick();
expect(wrapper.emitted("update:open")?.at(-1)).toEqual([false]);
});
});
describe("RecentSearchesFocusDropdown outside clicks", () => {
beforeEach(() => {
store.state.zoConfig.query_history_enabled = true;
setViewport(1440, 900);
});
afterEach(() => {
delete store.state.zoConfig.query_history_enabled;
setViewport(1024, 768);
document.body.innerHTML = "";
});
it("closes on a pointerdown elsewhere and lets that click reach its target", async () => {
const picker = document.createElement("button");
document.body.appendChild(picker);
const onPicker = vi.fn();
picker.addEventListener("click", onPicker);
const seenPointerdown = vi.fn();
document.addEventListener("pointerdown", seenPointerdown);
const wrapper = mountDropdown(editorWithCaret(FIRST_LINE_CARET).anchor);
expect(panel()).not.toBeNull();
const down = new MouseEvent("pointerdown", { bubbles: true, cancelable: true });
picker.dispatchEvent(down);
picker.click();
await nextTick();
expect(wrapper.emitted("update:open")?.at(-1)).toEqual([false]);
expect(down.defaultPrevented).toBe(false);
expect(seenPointerdown).toHaveBeenCalledTimes(1);
expect(onPicker).toHaveBeenCalledTimes(1);
document.removeEventListener("pointerdown", seenPointerdown);
});
it("stays open on a pointerdown in the list's details pane, which lives outside the panel", async () => {
const pane = document.createElement("div");
document.body.appendChild(pane);
ownsNode.mockImplementation((node) => node === pane);
const wrapper = mountDropdown(editorWithCaret(FIRST_LINE_CARET).anchor);
pane.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
pane.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
await nextTick();
expect(wrapper.emitted("update:open")).toBeUndefined();
ownsNode.mockReset();
ownsNode.mockReturnValue(false);
});
it("stays open on a pointerdown in the editor or the panel", async () => {
const { anchor, monaco } = editorWithCaret(FIRST_LINE_CARET);
const wrapper = mountDropdown(anchor);
monaco.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
panel()!.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
await nextTick();
expect(wrapper.emitted("update:open")).toBeUndefined();
expect(panel()).not.toBeNull();
});
});
describe("RecentSearchesFocusDropdown when the editor resizes while open", () => {
const observers: {
callback: ResizeObserverCallback;
targets: Element[];
boxes: (ResizeObserverBoxOptions | undefined)[];
}[] = [];
const RealResizeObserver = globalThis.ResizeObserver;
beforeEach(() => {
store.state.zoConfig.query_history_enabled = true;
setViewport(1440, 900);
observers.length = 0;
globalThis.ResizeObserver = class {
private entry: (typeof observers)[number];
constructor(callback: ResizeObserverCallback) {
this.entry = { callback, targets: [], boxes: [] };
observers.push(this.entry);
}
observe(target: Element, options?: ResizeObserverOptions) {
this.entry.targets.push(target);
this.entry.boxes.push(options?.box);
}
unobserve() {}
disconnect() {
this.entry.targets = [];
}
} as unknown as typeof ResizeObserver;
});
afterEach(() => {
globalThis.ResizeObserver = RealResizeObserver;
delete store.state.zoConfig.query_history_enabled;
setViewport(1024, 768);
document.body.innerHTML = "";
});
const fireResize = () =>
observers.forEach((o) => {
if (o.targets.length) o.callback([], {} as ResizeObserver);
});
it("stays at the caret as the editor re-lays out, and closes once it would go off-screen", async () => {
const { anchor, moveCaret } = editorWithCaret(FIRST_LINE_CARET);
const wrapper = mountDropdown(anchor);
const active = observers.find((o) => o.targets.length);
expect(active?.targets).toEqual([anchor]);
expect(active?.boxes).toEqual(["border-box"]);
moveCaret({ left: 113, top: 300, right: 115, bottom: 318 });
fireResize();
await nextTick();
expect(px(panel()!.style.top)).toBe(318);
setViewport(1440, 240);
moveCaret({ left: 113, top: 100, right: 115, bottom: 118 });
fireResize();
await nextTick();
expect(wrapper.emitted("update:open")?.at(-1)).toEqual([false]);
});
});
@@ -0,0 +1,219 @@
<!-- 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/>.
-->
<template>
<Teleport to="body">
<div
v-if="open && enabled && placement"
ref="panelRef"
class="bg-select-content-bg border-dropdown-border rounded-default fixed flex flex-col overflow-hidden border shadow-lg"
:style="position"
:data-side="placement.side"
data-test="recent-searches-focus-dropdown"
>
<RecentSearchesPopover
ref="listRef"
:source="source"
:active="open"
:examples="examples"
@run="(entry, when) => close(() => emit('run', entry, when))"
@load="(entry, when) => close(() => emit('load', entry, when))"
@view-all="(status) => close(() => emit('view-all', status))"
@example="(query) => close(() => emit('example', query))"
@close="close(() => emit('dismiss'))"
/>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useRecentSearches } from "@/composables/queryHistory/useRecentSearches";
import type { HistorySource, HistoryStatus, QueryHistoryEntry } from "@/services/query_history";
import { isQueryEditorInput, type RestoreTime } from "@/utils/queryHistory/recentSearches";
import { observeMoves } from "@/utils/queryHistory/observeMoves";
import RecentSearchesPopover from "./RecentSearchesPopover.vue";
// OPopover focuses its content on open; this panel must leave focus in the editor.
const DROPDOWN_Z_INDEX = 6000;
const EDGE_GAP_PX = 8;
const MIN_ROOM_PX = 160;
const CARET_SELECTOR = ".monaco-editor .cursors-layer .cursor";
const props = withDefaults(
defineProps<{
source: HistorySource;
anchor: HTMLElement | null;
examples?: string[];
}>(),
{ examples: () => [] },
);
const emit = defineEmits<{
run: [entry: QueryHistoryEntry, when: RestoreTime];
load: [entry: QueryHistoryEntry, when: RestoreTime];
"view-all": [status: HistoryStatus | null];
example: [query: string];
dismiss: [];
}>();
const open = defineModel<boolean>("open", { default: false });
const { enabled } = useRecentSearches();
const panelRef = ref<HTMLElement | null>(null);
const listRef = ref<InstanceType<typeof RecentSearchesPopover> | null>(null);
interface Placement {
side: "below" | "above";
viewportOffset: number;
left: number;
maxHeight: number;
}
const placement = ref<Placement | null>(null);
const position = computed(() => {
const at = placement.value;
if (!at) return { zIndex: DROPDOWN_Z_INDEX };
return {
[at.side === "below" ? "top" : "bottom"]: `${at.viewportOffset}px`,
left: `${at.left}px`,
maxHeight: `${at.maxHeight}px`,
zIndex: DROPDOWN_Z_INDEX,
};
});
const caretRect = () => props.anchor?.querySelector(CARET_SELECTOR)?.getBoundingClientRect();
const place = (): Placement | null => {
const caret = caretRect();
if (!caret || caret.bottom <= 0 || caret.top >= window.innerHeight) return null;
const width = panelRef.value?.offsetWidth ?? 0;
const left = Math.max(EDGE_GAP_PX, Math.min(caret.left, window.innerWidth - width - EDGE_GAP_PX));
const roomBelow = window.innerHeight - caret.bottom - EDGE_GAP_PX;
if (roomBelow >= MIN_ROOM_PX) {
return { side: "below", viewportOffset: caret.bottom, left, maxHeight: roomBelow };
}
const roomAbove = caret.top - EDGE_GAP_PX;
if (roomAbove >= MIN_ROOM_PX) {
return {
side: "above",
viewportOffset: window.innerHeight - caret.top,
left,
maxHeight: roomAbove,
};
}
return null;
};
const close = (then: () => void) => {
open.value = false;
then();
};
// Only the editor's text surface keeps the panel open; the AI prompt and buttons in the same box do not.
const onOutside = (event: Event) => {
const target = event.target as Node | null;
if (target && panelRef.value?.contains(target)) return;
if (listRef.value?.ownsNode(target)) return;
if (target && props.anchor?.contains(target) && isQueryEditorInput(target)) return;
open.value = false;
};
let placedCaret = "";
let stopObserving: (() => void) | null = null;
let panelSizes: ResizeObserver | null = null;
const caretKey = () => {
const caret = caretRect();
const width = panelRef.value?.offsetWidth ?? 0;
if (!caret) return "";
return `${caret.left}|${caret.top}|${caret.bottom}|${width}|${window.innerWidth}|${window.innerHeight}`;
};
const reposition = () => {
if (!open.value) return;
placement.value = place();
placedCaret = caretKey();
if (!placement.value) open.value = false;
};
// Editors rewrite inline styles on every keystroke and paint, so only a real move re-places the panel.
const repositionIfMoved = () => {
if (open.value && caretKey() !== placedCaret) reposition();
};
const listen = (on: boolean) => {
const method = on ? "addEventListener" : "removeEventListener";
document[method]("pointerdown", onOutside, true);
document[method]("focusin", onOutside, true);
};
// Monaco moves its caret, and scrolls its lines, by rewriting inline styles inside the editor.
const observe = (anchor: HTMLElement | null) => {
stopObserving?.();
stopObserving = anchor ? observeMoves(anchor, repositionIfMoved, { subtreeStyles: true }) : null;
};
watch(panelRef, (panel) => {
panelSizes?.disconnect();
panelSizes = null;
if (!panel || typeof ResizeObserver === "undefined") return;
panelSizes = new ResizeObserver(repositionIfMoved);
panelSizes.observe(panel);
});
watch(
open,
(isOpen) => {
placement.value = isOpen ? place() : null;
if (isOpen && !placement.value) {
open.value = false;
return;
}
placedCaret = isOpen ? caretKey() : "";
listen(isOpen);
observe(isOpen ? props.anchor : null);
},
{ immediate: true },
);
// The anchor arrives with the editor's focus; the open watcher does the first placement.
watch(
() => props.anchor,
(anchor) => {
if (!open.value || !placement.value) return;
observe(anchor);
reposition();
},
);
onBeforeUnmount(() => {
listen(false);
observe(null);
panelSizes?.disconnect();
});
/** Moves focus from the editor into the list; false when there is no row to focus. */
const focusFirst = () => listRef.value?.focusFirst() ?? false;
/** Runs, or with Shift loads, the hovered row while focus is still in the editor. */
const pickActive = (event: KeyboardEvent) => listRef.value?.pickActive(event) ?? false;
/** Pages the active row's details; false when no row is active, so the editor keeps the key. */
const scrollDetails = (step: 1 | -1) => listRef.value?.scrollDetails(step) ?? false;
defineExpose({ focusFirst, pickActive, scrollDetails });
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,435 @@
<!-- 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/>.
-->
<template>
<div
ref="rootRef"
tabindex="-1"
class="text-select-item-text flex min-h-0 w-[min(26.75rem,calc(100vw-1rem))] max-w-full flex-col font-sans text-[0.8125rem] outline-none"
data-test="recent-searches-popover"
@keydown="onKeydown"
>
<div
class="border-border-subtle text-dropdown-label text-2xs flex h-6 shrink-0 items-center gap-1.5 border-b ps-2 pe-1 font-semibold uppercase"
>
<OIcon name="history" size="xs" />
<span class="truncate">{{ t(`recentSearches.pageTitle.${source}`) }}</span>
<OButton
variant="ghost"
size="icon-xs"
icon-left="info-outline"
class="ms-auto normal-case"
:aria-label="`${about} ${t('recentSearches.keyHints')}`"
data-test="recent-searches-about"
>
<OTooltip>
<template #content>
<span class="flex flex-col gap-1">
<span>{{ about }}</span>
<span data-test="recent-searches-key-hints">{{ t("recentSearches.keyHints") }}</span>
</span>
</template>
</OTooltip>
</OButton>
</div>
<OEmptyState
v-if="list.isError.value"
size="inline"
icon="error"
:title="t('recentSearches.loadError')"
:action-label="t('recentSearches.retry')"
action-icon="refresh"
data-test="recent-searches-error"
@action="list.refetch()"
/>
<div v-else-if="list.isPending.value" class="flex flex-col gap-2 p-2">
<OSkeleton v-for="n in 4" :key="n" type="text" />
</div>
<div v-else-if="isEmpty" class="flex flex-col items-center gap-2 p-3">
<OEmptyState
size="inline"
icon="history"
:title="t('recentSearches.emptyTitle')"
data-test="recent-searches-empty"
/>
<div v-if="examples.length" class="flex flex-wrap justify-center gap-2">
<OButton
v-for="(example, index) in examples"
:key="index"
variant="outline"
size="xs"
icon-left="lightbulb"
:data-test="`recent-searches-example-${index}`"
@click="emit('example', example)"
>
<OTruncatedText class="max-w-60 font-mono">{{ example }}</OTruncatedText>
</OButton>
</div>
</div>
<div
v-else
ref="listRef"
role="listbox"
:aria-label="t('recentSearches.title')"
class="max-h-72 min-h-0 overflow-auto"
data-test="recent-searches-list"
@pointerenter="keepHover"
@pointerleave="onPointerLeave"
>
<div
v-if="view.pinnedFailure"
:class="[ROW_ACTIVE_CLASSES, 'flex flex-col gap-0.5 py-0.5 ps-1.5 pe-1']"
:data-highlighted="highlighted(view.pinnedFailure.entry.id)"
data-test="recent-searches-pinned-failure"
@pointerenter="onRowEnter(view.pinnedFailure.entry.id)"
>
<div class="flex h-6 min-w-0 items-center gap-2">
<button
type="button"
role="option"
data-recent-row
:data-recent-id="view.pinnedFailure.entry.id"
:aria-selected="activeId === view.pinnedFailure.entry.id"
class="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-2 text-left outline-none"
@focus="onRowFocus(view.pinnedFailure.entry.id)"
@blur="onRowBlur"
@click="pick(view.pinnedFailure.entry, $event, true)"
@keydown.enter.exact.prevent="pick(view.pinnedFailure.entry, $event, true)"
@keydown.shift.enter.exact.prevent="pick(view.pinnedFailure.entry, $event, true)"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<OIcon name="error" size="xs" class="text-status-error-text" />
</span>
<OTruncatedText class="flex-1 font-mono text-xs">
{{ oneLine(view.pinnedFailure.entry.query) }}
</OTruncatedText>
</button>
<OButton
variant="outline"
size="xs"
data-test="recent-searches-load-to-fix"
@click="emit('load', view.pinnedFailure.entry, 'original')"
>
{{ t("recentSearches.loadToFix") }}
</OButton>
</div>
<OTruncatedText
v-if="view.pinnedFailure.entry.context.error"
:tooltip="raw(view.pinnedFailure.entry.context.error)"
class="text-status-error-text text-2xs ps-6"
data-test="recent-searches-pinned-error"
>
{{ view.pinnedFailure.entry.context.error }}
</OTruncatedText>
</div>
<button
v-for="row in view.rows"
:key="row.entry.id"
type="button"
role="option"
data-recent-row
:data-recent-id="row.entry.id"
:aria-selected="activeId === row.entry.id"
:data-highlighted="highlighted(row.entry.id)"
:class="[
ROW_ACTIVE_CLASSES,
'flex h-6 w-full cursor-pointer items-center gap-2 ps-1.5 pe-2 text-left outline-none',
]"
:data-test="`recent-searches-row-${row.entry.id}`"
@focus="onRowFocus(row.entry.id)"
@blur="onRowBlur"
@pointerenter="onRowEnter(row.entry.id)"
@click="pick(row.entry, $event, false)"
@keydown.enter.exact.prevent="pick(row.entry, $event, false)"
@keydown.shift.enter.exact.prevent="pick(row.entry, $event, false)"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<OIcon
:name="row.starred ? 'star' : 'history'"
size="xs"
:class="row.starred ? 'text-accent' : 'text-select-item-selected-text'"
/>
</span>
<OTruncatedText :tooltip="false" class="flex-1 font-mono text-xs">
{{ oneLine(row.entry.query) }}
</OTruncatedText>
<span
v-if="row.entry.context.warning !== undefined"
class="bg-status-warning-text size-2 shrink-0 rounded-full"
data-test="recent-searches-warning-dot"
>
<OTooltip :content="t('recentSearches.warning')" />
</span>
<OTimeCell
:value="row.entry.updated_at"
unit="us"
mode="relative"
class="text-text-secondary text-2xs shrink-0"
/>
</button>
</div>
<div
class="border-border-subtle text-text-secondary flex h-7 shrink-0 items-center gap-1 border-t px-1 text-xs"
>
<OButton
variant="ghost-primary"
size="xs"
icon-left="history"
data-test="recent-searches-view-all"
@click="emit('view-all', null)"
>
{{ t("recentSearches.viewAll") }}
</OButton>
<OButton
v-if="failedCount > 0"
variant="ghost-muted"
size="xs"
data-test="recent-searches-view-failed"
@click="emit('view-all', 'error')"
>
{{ t("recentSearches.failedCount", { count: failedCount }) }}
</OButton>
</div>
<RecentSearchesDetails
v-if="activeEntry"
ref="detailsRef"
:entry="activeEntry"
:anchor="rootRef"
@enter="onPaneEnter"
@leave="onPointerLeave"
@focus-leave="onPaneFocusLeave"
@back="focusHeldRow"
@escape="emit('close')"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, toRef } from "vue";
import { raw, useI18nTyped } from "@/types/i18n";
import {
useRecentFailedCount,
useRecentSearches,
useRecentSearchesList,
} from "@/composables/queryHistory/useRecentSearches";
import { useRecentEntryText } from "@/composables/queryHistory/useRecentEntryText";
import {
buildRecents,
defaultRestoreTime,
type RestoreTime,
} from "@/utils/queryHistory/recentSearches";
import type { HistorySource, HistoryStatus, QueryHistoryEntry } from "@/services/query_history";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import RecentSearchesDetails from "./RecentSearchesDetails.vue";
const ROW_ACTIVE_CLASSES =
"border-s-2 border-transparent data-[highlighted]:border-accent data-[highlighted]:bg-select-item-selected-bg";
const HOVER_LEAVE_GRACE_MS = 200;
const props = withDefaults(
defineProps<{
source: HistorySource;
/** Fetches while true, so a closed popover costs nothing. */
active: boolean;
/** Example queries for the first-time empty state. */
examples?: string[];
}>(),
{ examples: () => [] },
);
const emit = defineEmits<{
run: [entry: QueryHistoryEntry, when: RestoreTime];
load: [entry: QueryHistoryEntry, when: RestoreTime];
"view-all": [status: HistoryStatus | null];
example: [query: string];
close: [];
}>();
const { t } = useI18nTyped();
const { zone } = useRecentEntryText();
const { about } = useRecentSearches();
const list = useRecentSearchesList(props.source, toRef(props, "active"));
const failed = useRecentFailedCount(props.source, toRef(props, "active"));
const rootRef = ref<HTMLElement | null>(null);
const listRef = ref<HTMLElement | null>(null);
const detailsRef = ref<InstanceType<typeof RecentSearchesDetails> | null>(null);
const view = computed(() => buildRecents(list.data.value ?? []));
// The server counts the pinned failure too, and the two answers can land apart.
const failedCount = computed(() =>
Math.max(0, (failed.data.value ?? 0) - (view.value.pinnedFailure ? 1 : 0)),
);
const isEmpty = computed(() => !view.value.pinnedFailure && view.value.rows.length === 0);
const oneLine = (query: string) => query.replace(/\s+/g, " ").trim();
// One row at a time carries the menu's active state: the hovered one, else the keyboard-focused one, else the one whose details hold the pointer or focus.
const focusedId = ref<string | null>(null);
const hoveredId = ref<string | null>(null);
const heldId = ref<string | null>(null);
const activeId = computed(() => hoveredId.value ?? focusedId.value ?? heldId.value);
const highlighted = (id: string) => (activeId.value === id ? "" : undefined);
const activeEntry = computed(() => {
const id = activeId.value;
if (!id) return null;
if (view.value.pinnedFailure?.entry.id === id) return view.value.pinnedFailure.entry;
return view.value.rows.find((row) => row.entry.id === id)?.entry ?? null;
});
// The pointer crosses the gap to the details pane, or brushes the list's edge, without meaning to drop the row.
let leaveTimer: ReturnType<typeof setTimeout> | undefined;
const keepHover = () => clearTimeout(leaveTimer);
const onPointerLeave = (event: PointerEvent) => {
const to = event.relatedTarget as Node | null;
if (to && (detailsRef.value?.contains(to) || listRef.value?.contains(to))) return;
clearTimeout(leaveTimer);
leaveTimer = setTimeout(() => {
hoveredId.value = null;
if (!detailsRef.value?.hasFocus()) heldId.value = null;
}, HOVER_LEAVE_GRACE_MS);
};
onBeforeUnmount(() => clearTimeout(leaveTimer));
// Moving to the pane blurs or un-hovers the row before the pane is used, which would unmount it under the pointer or focus.
const holdActive = () => {
if (activeId.value) heldId.value = activeId.value;
};
const onRowEnter = (id: string) => {
keepHover();
hoveredId.value = id;
heldId.value = null;
};
const onRowFocus = (id: string) => {
focusedId.value = id;
heldId.value = null;
};
const onRowBlur = (event: FocusEvent) => {
if (detailsRef.value?.contains(event.relatedTarget as Node | null)) holdActive();
focusedId.value = null;
};
const onPaneEnter = () => {
keepHover();
holdActive();
};
const onPaneFocusLeave = (to: Node | null) => {
if (listRef.value?.contains(to)) return;
if (!detailsRef.value?.hasPointer()) heldId.value = null;
};
const focusHeldRow = () => {
const id = activeId.value;
rows()
.find((row) => row.dataset.recentId === id)
?.focus();
};
// Shift loads without running; a failed row always loads, since running it again fails again.
const pick = (entry: QueryHistoryEntry, event: MouseEvent | KeyboardEvent, failed: boolean) => {
const when = defaultRestoreTime(entry, Date.now(), zone.value);
if (failed || event.shiftKey) emit("load", entry, when);
else emit("run", entry, when);
};
const rows = () => [...(rootRef.value?.querySelectorAll<HTMLElement>("[data-recent-row]") ?? [])];
const moveFocus = (step: number) => {
const all = rows();
if (!all.length) return;
const at = all.indexOf(document.activeElement as HTMLElement);
const next = at < 0 ? (step > 0 ? 0 : all.length - 1) : (at + step + all.length) % all.length;
all[next].focus();
};
/** Pages the active row's details; false when no row is active, so the key stays with its owner. */
const scrollDetails = (step: 1 | -1) => detailsRef.value?.scrollPage(step) ?? false;
/** Whether a node belongs to the list, including its details pane outside the list's DOM. */
const ownsNode = (node: Node | null) =>
!!node && (!!rootRef.value?.contains(node) || !!detailsRef.value?.contains(node));
const isPlainEnter = (event: KeyboardEvent) =>
event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey;
const onKeydown = (event: KeyboardEvent) => {
const onRow = event.target instanceof HTMLElement && event.target.hasAttribute("data-recent-row");
// With focus on the list itself, Enter belongs to the hovered row and must not reach the page's run shortcut.
if (isPlainEnter(event) && event.target === rootRef.value) {
if (pickActive(event)) {
event.preventDefault();
event.stopPropagation();
}
} else if (event.key === "Tab" && !event.shiftKey && onRow && activeEntry.value) {
holdActive();
if (detailsRef.value?.focusCopy()) event.preventDefault();
} else if (event.key === "PageDown" || event.key === "PageUp") {
if (scrollDetails(event.key === "PageDown" ? 1 : -1)) event.preventDefault();
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveFocus(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "Escape") {
event.preventDefault();
emit("close");
}
};
/** Moves keyboard focus into the list, for the editor's ArrowDown handoff. */
const focusFirst = () => {
const first = rows()[0];
if (!first) return false;
first.focus();
return true;
};
/** Runs, or with Shift loads, the active row, as Enter would on it; false when no row is active. */
const pickActive = (event: KeyboardEvent) => {
const entry = activeEntry.value;
if (!entry) return false;
pick(entry, event, entry.id === view.value.pinnedFailure?.entry.id);
return true;
};
/** Handles a key pressed while focus is still outside the list; true when it was used. */
const handleOutsideKey = (event: KeyboardEvent) => {
if (isPlainEnter(event)) return hoveredId.value !== null && pickActive(event);
if (event.key === "PageDown" || event.key === "PageUp") {
onKeydown(event);
return event.defaultPrevented;
}
if (!["ArrowDown", "ArrowUp", "Escape"].includes(event.key)) return false;
onKeydown(event);
return true;
};
/** Focuses the list itself rather than its first control, so Enter reaches the hovered row. */
const focusRoot = () => rootRef.value?.focus();
defineExpose({ focusFirst, focusRoot, pickActive, handleOutsideKey, scrollDetails, ownsNode });
</script>
@@ -13,24 +13,82 @@
// 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 { useMutation } from "@tanstack/vue-query";
import { useOrgId } from "@/composables/query/useOrgId";
import { recordQueryHistoryMutation } from "@/services/query_history.queries";
import { buildHistoryEntry } from "@/utils/metrics/queryHistory";
import { nextTick, watch } from "vue";
import { useRecentSearches } from "@/composables/queryHistory/useRecentSearches";
import {
buildMetricsRecord,
metricsRunError,
withRunResult,
type MetricsPanelState,
type PanelErrorDetail,
} from "@/utils/queryHistory/metrics";
import { runResultFromError, type RunError } from "@/utils/queryHistory/recentSearches";
import type { QueryHistoryRecordBody } from "@/services/query_history";
import type { SelectedDate } from "@/utils/dashboard/urlTimeParams";
/** Fire-and-forget: a failed record is only logged and never reaches the run. */
export const useQueryHistoryRecorder = () => {
const orgId = useOrgId();
const recordEntry = useMutation(() => recordQueryHistoryMutation(orgId.value));
interface RecordedPanel extends MetricsPanelState {
meta?: { dateTime?: { start_time?: unknown; end_time?: unknown } };
}
const record = (dashboardPanelData: any, selectedDate: Partial<SelectedDate> | null) => {
const entry = buildHistoryEntry(dashboardPanelData, selectedDate);
if (!entry) return;
recordEntry.mutateAsync(entry).catch((err) => {
console.error("Failed to record query history", err);
});
interface PendingRun {
body: QueryHistoryRecordBody;
started: boolean;
}
// Panel pages build these Dates from microseconds, so getTime() is already microseconds.
const toMicros = (value: unknown): number | null => {
const us = value instanceof Date ? value.getTime() : Number.NaN;
return Number.isFinite(us) ? us : null;
};
/** Records a user's Run once the panel request settles; a failed record never reaches the run. */
export const useQueryHistoryRecorder = (isLoading: () => boolean) => {
const recent = useRecentSearches();
let pending: PendingRun | null = null;
// Mirrors the panel's error: the panel emits only on change, so a repeat of the same error keeps it.
let panelError: RunError | null = null;
const settle = (body: QueryHistoryRecordBody) => {
const result = panelError ? runResultFromError(panelError) : { status: "ok" as const };
if (result) recent.record(withRunResult(body, result));
};
return { record };
watch(isLoading, (loading) => {
if (!pending) return;
if (loading) {
pending.started = true;
return;
}
if (!pending.started) return;
const { body } = pending;
pending = null;
// The panel's error arrives through its own watcher in the same flush as the loading change.
nextTick(() => settle(body));
});
/** Captures the run as dispatched; it is recorded when the panel's request settles. */
const record = (
dashboardPanelData: RecordedPanel,
selectedDate: Partial<SelectedDate> | null,
) => {
const startUs = toMicros(dashboardPanelData?.meta?.dateTime?.start_time);
const endUs = toMicros(dashboardPanelData?.meta?.dateTime?.end_time);
const body = buildMetricsRecord({
panel: dashboardPanelData,
selectedDate,
window: startUs !== null && endUs !== null ? { startUs, endUs } : null,
});
pending = body ? { body, started: isLoading() } : null;
};
const onPanelError = (detail: PanelErrorDetail | string | null | undefined) => {
panelError = metricsRunError(detail);
};
/** A cancelled run is never recorded. */
const cancel = () => {
pending = null;
};
return { record, onPanelError, cancel };
};
@@ -37,3 +37,31 @@ describe("purgeOrgQueries on a stale deploy", () => {
expect(unhandled).not.toHaveBeenCalled();
});
});
describe("a mutation's stales meta", () => {
it("marks the scope stale without refetching a query that is in use", async () => {
const { queryClient } = await import("./queryClient");
const { QueryObserver } = await import("@tanstack/query-core");
const fetches = vi.fn().mockResolvedValue(["row"]);
const observer = new QueryObserver(queryClient, {
queryKey: ["stales-test", "list"],
queryFn: fetches,
staleTime: Infinity,
});
const unsubscribe = observer.subscribe(() => {});
await vi.waitFor(() => expect(fetches).toHaveBeenCalledTimes(1));
await queryClient
.getMutationCache()
.build(queryClient, {
mutationFn: async () => "ok",
meta: { stales: [["stales-test"]] },
})
.execute(undefined);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(fetches).toHaveBeenCalledTimes(1);
expect(queryClient.getQueryState(["stales-test", "list"])?.isInvalidated).toBe(true);
unsubscribe();
});
});
+5
View File
@@ -51,6 +51,8 @@ declare module "@tanstack/query-core" {
mutationMeta: {
/** Scopes to refetch. Prefer a domain's `all` key over a precise one. */
invalidates?: readonly (readonly unknown[])[];
/** Scopes marked stale without a refetch: each refetches the next time a reader mounts it. */
stales?: readonly (readonly unknown[])[];
/**
* Scopes whose *inactive* entries are dropped outright. Use after a
* delete: invalidation alone leaves the deleted entity's detail query
@@ -90,6 +92,9 @@ export const queryClient = new QueryClient({
for (const key of meta.invalidates ?? []) {
void queryClient.invalidateQueries({ queryKey: key });
}
for (const key of meta.stales ?? []) {
void queryClient.invalidateQueries({ queryKey: key, refetchType: "none" });
}
if (meta.successMessage) {
notifyMutation("success", meta.successMessage);
}
@@ -0,0 +1,42 @@
// 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 { onBeforeUnmount } from "vue";
import { useLogsAutoRun } from "@/composables/useLogs/logsAutoRun";
import { searchState } from "@/composables/useLogs/searchState";
import { useRecentSearches } from "./useRecentSearches";
import { buildLogsRecord, isRecordedLogsRun, logsRunResult } from "@/utils/queryHistory/logs";
/** Records each user-initiated Logs search once it finishes or fails. */
export const useLogsHistoryRecorder = () => {
const { record } = useRecentSearches();
const { searchObj } = searchState();
const stop = useLogsAutoRun().onRunSettled((run) => {
if (!isRecordedLogsRun(run.reasons, run.signature)) return;
const result = logsRunResult(run);
if (!result) return;
const fields: unknown = searchObj.data.stream?.selectedFields;
record(
buildLogsRecord({
signature: run.signature,
startUs: run.startUs,
endUs: run.endUs,
columns: Array.isArray(fields) ? fields.map(String) : [],
result,
}),
);
});
onBeforeUnmount(stop);
};
@@ -0,0 +1,93 @@
// 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, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import { defineComponent } from "vue";
import store from "@/test/unit/helpers/store";
import type { QueryHistoryEntry } from "@/services/query_history";
import { useRecentEntryText } from "./useRecentEntryText";
const at = (iso: string) => Date.parse(iso) * 1000;
const entry = (overrides: Partial<QueryHistoryEntry>): QueryHistoryEntry => ({
id: "e",
source: "logs",
query: "q",
query_key: "k",
streams: [],
context: {},
status: "ok",
starred: false,
run_count: 1,
created_at: at("2026-10-29T10:00:00Z"),
updated_at: at("2026-10-29T10:00:00Z"),
...overrides,
});
const text = () => {
let api!: ReturnType<typeof useRecentEntryText>;
mount(
defineComponent({
setup() {
api = useRecentEntryText();
return () => null;
},
}),
{ global: { plugins: [store] } },
);
return api;
};
describe("useRecentEntryText", () => {
it("shows a same-day run span as times only", () => {
const span = text().spanText(entry({ run_count: 3, created_at: at("2026-10-29T08:05:00Z") }));
expect(span).toBe("ran 3x · 08:05–10:00");
});
it("dates both ends of a run span that covers several days", () => {
const span = text().spanText(entry({ run_count: 4, created_at: at("2026-10-26T09:12:00Z") }));
expect(span).toBe("ran 4x · Oct 26 09:12 – Oct 29 10:00");
});
it("names a calendar preset by its rail label, not its stored token", () => {
const api = text();
const relative = (period: string) =>
api.windowText(
entry({ context: { time: { type: "relative", period, start_time: 0, end_time: 0 } } }),
);
expect(relative("calendar:day:0")).toBe("Today");
expect(relative("calendar:week:0")).toBe("This week");
expect(relative("calendar:month:-1")).toBe("Last month");
expect(relative("15m")).toBe("Last 15m");
expect(api.windowText(entry({ context: { time_range: { period: "calendar:day:-1" } } }))).toBe(
"Yesterday",
);
});
it("cuts days in the configured timezone", () => {
const original = store.state.timezone;
store.state.timezone = "Asia/Kolkata";
try {
const api = text();
expect(api.zone.value).toBe("Asia/Kolkata");
expect(api.spanText(entry({ run_count: 2, created_at: at("2026-10-28T20:00:00Z") }))).toBe(
"ran 2x · 01:30–15:30",
);
} finally {
store.state.timezone = original;
}
});
});
@@ -0,0 +1,64 @@
// 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 { computed } from "vue";
import { useStore } from "vuex";
import { DateTime } from "luxon";
import { useI18nTyped } from "@/types/i18n";
import type { QueryHistoryEntry } from "@/services/query_history";
import { getIntlLocale } from "@/locales";
import { formatCalendarLabel } from "@/utils/dashboard/calendarPeriods";
/** The time window and run span of a recent search, and the zone its days are cut in. */
export const useRecentEntryText = () => {
const store = useStore();
const { t } = useI18nTyped();
const zone = computed(() => {
const tz = String(store.state.timezone ?? "");
return !tz || ["local", "browser"].includes(tz.toLowerCase()) ? undefined : tz;
});
const at = (us: number) => DateTime.fromMillis(us / 1000, zone.value ? { zone: zone.value } : {});
const sameDay = (a: number, b: number) => at(a).hasSame(at(b), "day");
// A calendar preset is stored as its token ("calendar:day:0"), which reads as its rail label.
const periodText = (period: string): string =>
formatCalendarLabel(period, store.state.timezone, t, getIntlLocale()) ??
t("recentSearches.relativeWindow", { period });
const windowText = (entry: QueryHistoryEntry): string => {
const time = entry.context.time;
if (time?.type === "relative" && time.period) return periodText(time.period);
if (time) {
const end = sameDay(time.start_time, time.end_time) ? "HH:mm" : "LLL d HH:mm";
return `${at(time.start_time).toFormat("LLL d HH:mm")} – ${at(time.end_time).toFormat(end)}`;
}
const legacy = entry.context.time_range;
if (legacy && "period" in legacy) return periodText(legacy.period);
return "";
};
const spanText = (entry: QueryHistoryEntry): string => {
const last = at(entry.updated_at).toFormat("HH:mm");
if (entry.run_count <= 1) return last;
const ran = t("recentSearches.ranCount", { count: entry.run_count });
if (sameDay(entry.created_at, entry.updated_at)) {
return `${ran} · ${at(entry.created_at).toFormat("HH:mm")}–${last}`;
}
const span = `${at(entry.created_at).toFormat("LLL d HH:mm")} – ${at(entry.updated_at).toFormat("LLL d HH:mm")}`;
return `${ran} · ${span}`;
};
return { zone, windowText, spanText };
};
@@ -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 { computed, type Ref } from "vue";
import { useStore } from "vuex";
import { useMutation, useQuery } from "@tanstack/vue-query";
import { useOrgId } from "@/composables/query/useOrgId";
import { useI18nTyped } from "@/types/i18n";
import {
clearQueryHistoryMutation,
deleteQueryHistoryMutation,
recentFailedCountQuery,
recentSearchesQuery,
recordQueryHistoryMutation,
starQueryHistoryMutation,
} from "@/services/query_history.queries";
import type {
HistorySource,
QueryHistoryClearParams,
QueryHistoryRecordBody,
} from "@/services/query_history";
const DEFAULT_RETENTION_DAYS = 14;
/** Shared record, star, delete and clear; everything is off when the server disables history. */
export const useRecentSearches = () => {
const store = useStore();
const orgId = useOrgId();
const enabled = computed(() => store.state.zoConfig?.query_history_enabled === true);
const retentionDays = computed(() => {
const days = Number(store.state.zoConfig?.query_history_retention_days);
return Number.isFinite(days) ? days : DEFAULT_RETENTION_DAYS;
});
const recordEntry = useMutation(() => recordQueryHistoryMutation(orgId.value));
const starEntry = useMutation(() => starQueryHistoryMutation(orgId.value));
const deleteEntry = useMutation(() => deleteQueryHistoryMutation(orgId.value));
const clearEntries = useMutation(() => clearQueryHistoryMutation(orgId.value));
/** Fire-and-forget: a failed record is only logged and never reaches the search. */
const record = (body: QueryHistoryRecordBody | null) => {
if (!body || !enabled.value || !orgId.value) return;
recordEntry.mutateAsync(body).catch((err: unknown) => {
console.error("Failed to record query history", err);
});
};
const star = (id: string, starred: boolean) => starEntry.mutate({ id, starred });
/** Rejects when the server keeps the entry; the mutation has already toasted the error. */
const remove = (id: string, org = orgId.value) => deleteEntry.mutateAsync({ org, id });
const clear = (params: QueryHistoryClearParams) => clearEntries.mutateAsync(params);
const { t } = useI18nTyped();
/** Scope, privacy and retention of this org's recent searches. */
const about = computed(() =>
retentionDays.value > 0
? t(
"recentSearches.aboutKeptDays",
{ org: orgId.value, days: retentionDays.value },
retentionDays.value,
)
: t("recentSearches.aboutKeptForever", { org: orgId.value }),
);
return { orgId, enabled, retentionDays, about, record, star, remove, clear };
};
/** The recent rows of one page, fetched only while `active` is true. */
export const useRecentSearchesList = (source: HistorySource, active: Ref<boolean>) => {
const store = useStore();
const orgId = useOrgId();
return useQuery(() =>
Object.assign(recentSearchesQuery(orgId.value, source), {
enabled:
active.value && !!orgId.value && store.state.zoConfig?.query_history_enabled === true,
}),
);
};
/** How many of one page's entries failed, fetched only while `active` is true. */
export const useRecentFailedCount = (source: HistorySource, active: Ref<boolean>) => {
const store = useStore();
const orgId = useOrgId();
return useQuery(() =>
Object.assign(recentFailedCountQuery(orgId.value, source), {
enabled:
active.value && !!orgId.value && store.state.zoConfig?.query_history_enabled === true,
}),
);
};
@@ -0,0 +1,158 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h, inject } from "vue";
import { enableAutoUnmount, flushPromises, mount } from "@vue/test-utils";
import store from "@/test/unit/helpers/store";
import {
RECENT_SEARCHES_EDITOR_HOST,
useRecentSearchesEditorHost,
type RecentSearchesEditorHost,
} from "./useRecentSearchesEditorHost";
const api = vi.hoisted(() => ({ list: vi.fn(), record: vi.fn(), star: vi.fn(), remove: vi.fn() }));
vi.mock("@/services/query_history", () => ({ default: api }));
enableAutoUnmount(afterEach);
const NOW_US = Date.now() * 1000;
const row = (id: string, query: string) => ({
id,
source: "metrics",
query,
query_key: "k",
streams: [],
context: {},
status: "ok",
starred: false,
run_count: 1,
created_at: NOW_US,
updated_at: NOW_US,
});
const mountHost = (empty: { value: boolean }) => {
const seen: { host: RecentSearchesEditorHost | null } = { host: null };
const Editor = defineComponent({
setup() {
seen.host = inject(RECENT_SEARCHES_EDITOR_HOST, null);
return () => h("div");
},
});
const Page = defineComponent({
setup() {
const recent = useRecentSearchesEditorHost("metrics", {
opensOnFocus: () => empty.value,
acceptsQuery: (query) => !query.includes("\n"),
});
return { recent };
},
render: () => h(Editor),
});
const wrapper = mount(Page, { global: { plugins: [store] } });
const recent = () =>
(wrapper.vm as unknown as { recent: ReturnType<typeof useRecentSearchesEditorHost> }).recent;
return { wrapper, seen, recent };
};
describe("recent searches for a nested editor", () => {
beforeEach(() => {
vi.clearAllMocks();
store.state.zoConfig.query_history_enabled = true;
api.list.mockResolvedValue({
data: [row("a", "rate(x[5m])"), row("b", "up\nsum(up)"), row("c", "sum(up)")],
});
});
afterEach(() => {
delete store.state.zoConfig.query_history_enabled;
});
it("opens the dropdown when an empty editor gets focus and closes it on the first character", async () => {
const { seen, recent } = mountHost({ value: true });
const box = document.createElement("div");
seen.host!.onFocus(box);
await flushPromises();
expect(recent().open.value).toBe(true);
expect(recent().anchor.value).toBe(box);
expect(api.list).toHaveBeenCalledWith("default", { source: "metrics", status: "ok", limit: 8 });
seen.host!.onEdit(false, true);
expect(recent().open.value).toBe(false);
});
it("closes the dropdown on Escape and leaves the event to the editor's own Escape binding", async () => {
const { seen, recent } = mountHost({ value: true });
seen.host!.onFocus(document.createElement("div"));
await flushPromises();
const monaco = document.createElement("div");
monaco.className = "monaco-editor";
monaco.addEventListener("keydown", (e) => seen.host!.onKeydown(e));
const escape = new KeyboardEvent("keydown", { key: "Escape", cancelable: true });
const stop = vi.spyOn(escape, "stopPropagation");
monaco.dispatchEvent(escape);
expect(recent().open.value).toBe(false);
expect(stop).not.toHaveBeenCalled();
expect(escape.defaultPrevented).toBe(false);
});
it("opens when an edit empties the editor, closes on the next character, and respects a dismissal", async () => {
const { seen, recent } = mountHost({ value: false });
seen.host!.onFocus(document.createElement("div"));
await flushPromises();
expect(recent().open.value).toBe(false);
seen.host!.onEdit(true, false);
expect(recent().open.value).toBe(true);
seen.host!.onEdit(false, true);
expect(recent().open.value).toBe(false);
seen.host!.onEdit(true, false);
expect(recent().open.value).toBe(true);
recent().open.value = false;
seen.host!.onEdit(true, true);
expect(recent().open.value).toBe(false);
seen.host!.onEdit(false, true);
seen.host!.onEdit(true, false);
expect(recent().open.value).toBe(true);
});
it("reopens on a single clear after a restore wrote the editor without an edit event", async () => {
const { seen, recent } = mountHost({ value: true });
seen.host!.onFocus(document.createElement("div"));
await flushPromises();
expect(recent().open.value).toBe(true);
recent().open.value = false;
seen.host!.onEdit(true, false);
expect(recent().open.value).toBe(true);
});
it("keeps the dropdown closed when a query has text, but still loads clock rows", async () => {
const { seen, recent } = mountHost({ value: false });
seen.host!.onFocus(null);
await flushPromises();
expect(recent().open.value).toBe(false);
expect(seen.host!.queries.value).toEqual(["rate(x[5m])", "sum(up)"]);
});
it("offers nothing and opens nothing when history is disabled", async () => {
store.state.zoConfig.query_history_enabled = false;
const { seen, recent } = mountHost({ value: true });
seen.host!.onFocus(null);
await flushPromises();
expect(recent().open.value).toBe(false);
expect(seen.host!.queries.value).toEqual([]);
expect(api.list).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,86 @@
// 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 { computed, provide, ref, type ComputedRef, type InjectionKey, type Ref } from "vue";
import type { HistorySource } from "@/services/query_history";
import {
recentQueryList,
recentsOpenAfterEdit,
routeEditorKeyToRecents,
type RecentsKeyTarget,
} from "@/utils/queryHistory/recentSearches";
import { useRecentSearches, useRecentSearchesList } from "./useRecentSearches";
/** What a nested query editor reports to the page that owns the recent searches. */
export interface RecentSearchesEditorHost {
/** Recent queries the editor offers as clock rows. */
queries: ComputedRef<string[]>;
onFocus: (anchor: HTMLElement | null) => void;
/** Whether the editor text is blank after the edit and was blank before it. */
onEdit: (isBlank: boolean, wasBlank: boolean) => void;
/** Bound in the capture phase: the editor's Escape binding stops the event, and that binding must still run. */
onKeydown: (event: KeyboardEvent) => void;
}
export const RECENT_SEARCHES_EDITOR_HOST: InjectionKey<RecentSearchesEditorHost> = Symbol(
"recentSearchesEditorHost",
);
/** Provides recents to an editor several components down (Metrics' PanelEditor) and drives its focus dropdown. */
export const useRecentSearchesEditorHost = (
source: HistorySource,
options: {
/** True when focusing should open the dropdown, e.g. every visible query is empty. */
opensOnFocus: () => boolean;
/** Keeps only the queries one editor can take, e.g. no multi-query rows. */
acceptsQuery?: (query: string) => boolean;
/** Whether the editor counts as empty when its text is blank or not; defaults to the text alone. */
emptyWith?: (isBlank: boolean) => boolean;
},
) => {
const { enabled } = useRecentSearches();
const open = ref(false);
const anchor = ref<HTMLElement | null>(null);
const dropdownRef: Ref<RecentsKeyTarget | null> = ref(null);
const listActive = ref(false);
const list = useRecentSearchesList(source, listActive);
const accepts = options.acceptsQuery ?? (() => true);
const emptyWith = options.emptyWith ?? ((isBlank: boolean) => isBlank);
const queries = computed(() =>
enabled.value ? recentQueryList(list.data.value ?? []).filter(accepts) : [],
);
const host: RecentSearchesEditorHost = {
queries,
onFocus: (box) => {
if (!enabled.value) return;
anchor.value = box;
listActive.value = true;
if (options.opensOnFocus()) open.value = true;
},
onEdit: (isBlank, wasBlank) => {
const isEmpty = enabled.value && emptyWith(isBlank);
open.value = recentsOpenAfterEdit(open.value, emptyWith(wasBlank), isEmpty);
},
onKeydown: (event) => {
if (!open.value) return;
routeEditorKeyToRecents(event, dropdownRef.value, () => (open.value = false));
},
};
provide(RECENT_SEARCHES_EDITOR_HOST, host);
return { open, anchor, dropdownRef, queries };
};
@@ -779,3 +779,65 @@ describe("item 1: scan consent in the signature, the Stop guard and G1 inputs",
);
});
});
describe("recent searches: settled runs", () => {
const dispatch = (reason: "run" | "refresh") => {
let generation: { id: number } | null = null;
run().setExecutors({
logs: (ctx: { generation: { id: number } }) => {
generation = ctx.generation;
},
});
run().engine.requestRun(reason);
return generation as unknown as { id: number };
};
const hitsOf = (generationId: number, extra: Record<string, unknown> = {}) => ({
traceId: "t-hits",
type: "search",
generationId,
queryReq: { query: { start_time: 10, end_time: 20 } },
...extra,
});
it("reports the first page of a run with its reasons and request window", () => {
const settled = vi.fn();
run().onRunSettled(settled);
const hits = hitsOf(dispatch("run").id);
run().bindPayload(hits);
run().onPayloadComplete(hits);
expect(settled).toHaveBeenCalledTimes(1);
expect(settled.mock.calls[0][0]).toMatchObject({
reasons: ["run"],
startUs: 10,
endUs: 20,
partial: false,
error: null,
});
});
it("reports a failed search with its error", () => {
const settled = vi.fn();
run().onRunSettled(settled);
const hits = hitsOf(dispatch("run").id);
run().bindPayload(hits);
run().onPayloadError(hits, { code: 20001, message: "syntax" });
expect(settled.mock.calls[0][0].error).toEqual({ code: 20001, message: "syntax" });
});
it("never reports a page fetch, a histogram or a run after unsubscribing", () => {
const settled = vi.fn();
const stop = run().onRunSettled(settled);
const id = dispatch("run").id;
const page = hitsOf(id, { isPagination: true });
run().bindPayload(page);
run().onPayloadComplete(page);
run().onPayloadComplete({ traceId: "t-h", type: "histogram", generationId: id });
expect(settled).not.toHaveBeenCalled();
stop();
const hits = hitsOf(dispatch("run").id);
run().bindPayload(hits);
run().onPayloadComplete(hits);
expect(settled).not.toHaveBeenCalled();
});
});
+75 -4
View File
@@ -50,6 +50,7 @@ import { sqlSources } from "@/utils/logs/sqlSources";
import { freeTextGateFlags } from "@/utils/logs/freeTextScan";
import { notePageCancelled } from "@/composables/useLogs/logsRowNav";
import { noteUserScopeChange } from "@/composables/useLogs/useLogPermalink";
import type { RunError } from "@/utils/queryHistory/recentSearches";
export interface TransportPayload {
traceId: string;
@@ -59,6 +60,17 @@ export interface TransportPayload {
generationId?: number;
}
/** A Logs grid search that finished or failed, for the recent-searches recorder. */
export interface LogsRunSettled {
reasons: RunReason[];
signature: LogsSignature;
startUs: number;
endUs: number;
partial: boolean;
/** Null when the search finished. */
error: RunError | null;
}
export interface PanelConfigReader {
(surface: "visualize" | "build"): unknown;
}
@@ -136,6 +148,16 @@ function searchModeOf(toggle: string | undefined): SearchMode {
return MODES[toggle ?? "logs"] ?? "logs";
}
function requestWindow(queryReq: unknown): WindowUs | null {
const query = (queryReq as { query?: { start_time?: unknown; end_time?: unknown } } | null)
?.query;
const startUs = Number(query?.start_time);
const endUs = Number(query?.end_time);
return Number.isFinite(startUs) && Number.isFinite(endUs) && endUs > 0
? { startUs, endUs }
: null;
}
function toList(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String);
if (value && typeof value === "object") return Object.values(value).map(String);
@@ -222,6 +244,8 @@ function createLogsAutoRun() {
const generationEpoch = new Map<number, number>();
const pendingLaunches = new Map<number, number>();
const hitsDone = new Set<number>();
const runReasons = new Map<number, RunReason[]>();
const runSettledListeners = new Set<(run: LogsRunSettled) => void>();
const nowUs = () => Date.now() * 1000;
const streamList = () => (searchObj().data.streamResults?.list ?? []) as { name: string }[];
@@ -234,7 +258,13 @@ function createLogsAutoRun() {
getOrgId: () => searchObj().organizationIdentifier || "",
estimate: (signature) => estimateSignature(signature, streamList(), nowUs()),
executors: {
logs: (ctx) => executors.logs(ctx),
logs: (ctx) => {
if (ctx.op === "full") {
runReasons.clear();
runReasons.set(ctx.generation.id, [...ctx.reasons]);
}
return executors.logs(ctx);
},
patterns: (ctx) => executors.patterns(ctx),
histogram: (ctx) => executors.histogram(ctx),
visualize: (ctx) => executors.visualize(ctx),
@@ -361,16 +391,56 @@ function createLogsAutoRun() {
function onPayloadComplete(payload: TransportPayload): void {
const generationId = generationOf(payload);
if (generationId === null || generationId === undefined) return;
if (payload.type === "search") engine.recordComplete(generationId, payload.traceId);
if (payload.type !== "search") return;
emitRunSettled(payload, generationId, null);
engine.recordComplete(generationId, payload.traceId);
}
function onPayloadError(payload: TransportPayload): void {
function onPayloadError(payload: TransportPayload, error: RunError | null = null): void {
const generationId = generationOf(payload);
if (generationId === null || generationId === undefined) return;
if (payload.type === "search") engine.recordFailure(generationId, payload.traceId);
if (payload.type === "search") {
emitRunSettled(payload, generationId, error ?? {});
engine.recordFailure(generationId, payload.traceId);
}
finishPayload(payload);
}
// Only the first page of a full grid search counts; paging and histogram requests never do.
function emitRunSettled(
payload: TransportPayload,
generationId: number,
error: RunError | null,
): void {
const reasons = runReasons.get(generationId);
runReasons.delete(generationId);
if (payload.isPagination || !reasons || !runSettledListeners.size) return;
const gen = engine.currentGeneration("grid");
if (!gen || gen.id !== generationId || gen.surface !== "logs") return;
const window = requestWindow(payload.queryReq) ?? signatureWindow(gen.signature, nowUs());
if (!window) return;
const run: LogsRunSettled = {
reasons,
signature: gen.signature,
startUs: window.startUs,
endUs: window.endUs,
partial: error === null && searchObj().data.queryResults?.is_partial === true,
error,
};
runSettledListeners.forEach((listener) => {
try {
listener(run);
} catch (err) {
console.warn("autoRun: run-settled listener failed", err);
}
});
}
function onRunSettled(listener: (run: LogsRunSettled) => void): () => void {
runSettledListeners.add(listener);
return () => runSettledListeners.delete(listener);
}
function finishPayload(payload: TransportPayload): void {
const generationId = generationOf(payload);
traceGeneration.delete(payload.traceId);
@@ -546,6 +616,7 @@ function createLogsAutoRun() {
onPayloadData,
onPayloadComplete,
onPayloadError,
onRunSettled,
finishPayload,
trackLaunch,
finishDispatch,
@@ -0,0 +1,172 @@
// 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 { reactive } from "vue";
import { useSearchStream } from "@/composables/useLogs/useSearchStream";
import {
resetLogsAutoRunForTests,
useLogsAutoRun,
type LogsRunSettled,
} from "@/composables/useLogs/logsAutoRun";
import { logsRunResult } from "@/utils/queryHistory/logs";
interface StreamCallbacks {
onError: (payload: object, error: object) => void;
onComplete: (payload: object) => void;
}
const { fake, connection } = vi.hoisted(() => ({
fake: { searchObj: null as Record<string, unknown> | null },
connection: {
getDataThroughStream: vi.fn(),
cleanupConnection: vi.fn(),
buildWebSocketPayload: vi.fn(),
initializeSearchConnection: vi.fn(),
},
}));
vi.mock("vuex", () => ({ useStore: () => ({ state: { zoConfig: {} } }) }));
vi.mock("vue-router", () => ({
useRouter: () => ({ currentRoute: { value: { name: "logs", query: {} } }, push: vi.fn() }),
}));
vi.mock("@/composables/useLogs/searchState", () => ({
searchState: () => ({
searchObj: fake.searchObj,
resetQueryData: vi.fn(),
notificationMsg: { value: "" },
searchPartitionMap: {},
}),
}));
vi.mock("@/composables/useLogs/logsUtils", () => ({
logsUtils: () => ({ addTraceId: vi.fn(), removeTraceId: vi.fn() }),
}));
vi.mock("@/services/product_analytics", () => ({ default: { track: vi.fn() } }));
vi.mock("@/composables/useNotifications", () => ({
default: () => ({ showErrorNotification: vi.fn() }),
}));
vi.mock("@/composables/useLogs/useSearchQuery", () => ({
default: () => ({ getQueryReq: () => ({ query: { start_time: 10, end_time: 20 } }) }),
}));
vi.mock("@/composables/useLogs/useSearchConnection", () => ({ default: () => connection }));
vi.mock("@/composables/useLogs/useSearchResponseHandler", () => ({
default: () => ({ handleSearchError: vi.fn(), handleSearchResponse: vi.fn() }),
}));
vi.mock("@/composables/useLogs/useSearchHistogramManager", () => ({
default: () => ({ processHistogramRequest: vi.fn() }),
}));
vi.mock("@/composables/useLogs/useSearchPagination", () => ({ default: () => ({}) }));
vi.mock("@/aws-exports", () => ({ default: { isEnterprise: "false", isCloud: "false" } }));
const makeSearchObj = () =>
reactive({
organizationIdentifier: "org1",
loading: false,
loadingHistogram: false,
meta: {
sqlMode: true,
quickMode: false,
liveMode: false,
refreshInterval: 0,
showTransformEditor: false,
useUserDefinedSchemas: "user_defined_schema",
logsVisualizeToggle: "logs",
regions: [],
clusters: [],
},
data: {
query: 'SELECT * FROM "app" WHERE method == "GET"',
tempFunctionContent: "",
datetime: { type: "relative", relativeTimePeriod: "15m" },
stream: {
selectedStream: ["app"],
streamLists: [{ label: "app", value: "app" }],
streamType: "logs",
interestingFieldList: [],
},
resultGrid: { currentPage: 1, hitsSettled: true },
searchRequestTraceIds: [],
queryResults: { hits: [] },
histogram: { xData: [], yData: [] },
streamResults: { list: [] },
},
});
const startRun = async () => {
const autoRun = useLogsAutoRun();
const stream = useSearchStream((key: string) => key);
autoRun.setExecutors({
logs: (ctx) => stream.getDataThroughStream(false, ctx.generation.id),
});
autoRun.engine.requestRun("run");
await new Promise((resolve) => setTimeout(resolve, 0));
const [queryReq, , callbacks, generationId] = connection.getDataThroughStream.mock.calls.at(-1)!;
const payload = { traceId: "t1", type: "search", generationId, queryReq };
autoRun.bindPayload(payload);
return { callbacks: callbacks as StreamCallbacks, payload };
};
const settled = (): LogsRunSettled[] => {
const runs: LogsRunSettled[] = [];
useLogsAutoRun().onRunSettled((run) => runs.push(run));
return runs;
};
describe("Logs run status from the search stream", () => {
beforeEach(() => {
vi.clearAllMocks();
fake.searchObj = makeSearchObj();
resetLogsAutoRunForTests();
});
it("records a streamed schema error (HTTP 400 in `code`) as failed", async () => {
const runs = settled();
const { callbacks, payload } = await startRun();
callbacks.onError(payload, {
type: "error",
content: {
code: 400,
message: 'Search field not found: Schema error: No field named "GET".',
error_detail: "",
trace_id: "t1",
},
});
expect(runs).toHaveLength(1);
expect(logsRunResult(runs[0])).toEqual({
status: "error",
error: 'Search field not found: Schema error: No field named "GET".',
});
});
it("records a refused request (HTTP status plus search code) as failed", async () => {
const runs = settled();
const { callbacks, payload } = await startRun();
callbacks.onError(payload, {
type: "error",
content: { status: 400, code: 20001, message: "Search SQL not valid", trace_id: "t1" },
});
expect(logsRunResult(runs[0])?.status).toBe("error");
});
it("records a streamed timeout as ok with a warning", async () => {
const runs = settled();
const { callbacks, payload } = await startRun();
callbacks.onError(payload, {
type: "error",
content: { code: 408, message: "Search query timed out", trace_id: "t1" },
});
expect(logsRunResult(runs[0])).toEqual({ status: "ok", warning: "Search query timed out" });
});
});
@@ -35,12 +35,18 @@ import type { TranslateFn } from "@/types/i18n";
import analytics from "@/services/product_analytics";
import { flushEditorSuggestSummary } from "@/utils/query/editorSuggestStats";
import { useLogsAutoRun } from "@/composables/useLogs/logsAutoRun";
import { toRunError, type RunError } from "@/utils/queryHistory/recentSearches";
import {
failPendingPageNavigation,
notePageLoad,
notifyHitsComplete,
} from "@/composables/useLogs/logsRowNav";
const runErrorOf = (error: unknown): RunError =>
toRunError(
(error as { content?: { code?: unknown; message?: unknown; status?: unknown } })?.content ?? {},
);
export const useSearchStream = (t: TranslateFn) => {
const { showErrorNotification } = useNotifications();
@@ -66,7 +72,7 @@ export const useSearchStream = (t: TranslateFn) => {
autoRun.finishPayload(payload);
return;
}
autoRun.onPayloadError(payload);
autoRun.onPayloadError(payload, runErrorOf(error));
responseProcessor.handleSearchError(payload, error);
};
+1
View File
@@ -170,6 +170,7 @@ const defaultObject = {
streamResults: <any>[],
histogram: <any>{},
editorValue: "",
appliedEditorValue: null as string | null,
datetime: {
startTime: 0,
endTime: 0,
@@ -99,6 +99,24 @@ describe("OTruncatedText", () => {
expect(tooltipBubble()?.textContent).toContain("Payment service (production)");
});
it("should give the tooltip bubble the requested classes, keeping the text's line breaks", async () => {
vi.useFakeTimers();
const text = "SELECT *\n FROM k8s\n WHERE a = 1";
wrapper = mount(OTruncatedText, {
props: { lines: 2, tooltipClass: "whitespace-pre-wrap font-mono" },
slots: { default: () => text },
attachTo: document.body,
});
const root = wrapper.find('[data-test="o-truncated-text"]').element;
setWidths(root, 310, 200);
await hoverPastDelay(root);
expect(tooltipBubble()?.className).toContain("whitespace-pre-wrap");
expect(tooltipBubble()?.className).toContain("font-mono");
expect(tooltipBubble()?.textContent).toContain(text);
});
it("should render no tooltip at all when tooltip is false", () => {
wrapper = mount(OTruncatedText, { props: { tooltip: false }, slots: { default: "x" } });
expect(wrapper.findComponent(OTooltip).exists()).toBe(false);
@@ -10,6 +10,8 @@ export interface TruncatedTextProps {
lines?: TruncatedTextLines;
/** Tooltip text when cut (defaults to the element's own text); `false` when the full value is visible nearby. */
tooltip?: I18nText | false;
/** Classes for the tooltip bubble, e.g. `whitespace-pre-wrap` to keep the text's line breaks. */
tooltipClass?: string;
}
export interface TruncatedTextSlots {
@@ -11,6 +11,7 @@ const props = withDefaults(defineProps<TruncatedTextProps>(), {
as: "span",
lines: 1,
tooltip: undefined,
tooltipClass: undefined,
});
defineSlots<TruncatedTextSlots>();
@@ -38,7 +39,12 @@ const tooltipContent = computed(() => (props.tooltip === false ? undefined : pro
:data-o-tooltip-off="tooltip === false ? '' : undefined"
>
<!-- First child on purpose: with no element before it, the tooltip attaches to this root. -->
<OTooltip v-if="tooltip !== false" overflow-only :content="tooltipContent" />
<OTooltip
v-if="tooltip !== false"
overflow-only
:content="tooltipContent"
:class="tooltipClass"
/>
<slot />
</component>
</template>
@@ -21,6 +21,12 @@ describe("toast()", () => {
expect(typeof dismiss).toBe("function");
});
it("carries the id of its record, whatever the message became", () => {
const dismiss = toast({ variant: "success", message: "deleted x" });
expect(toastRecords[0].message).toBe("Deleted x");
expect(dismiss.id).toBe(toastRecords[0].id);
});
it("calling DismissFn() sets open: false on the record", () => {
const dismiss = toast({ variant: "success", message: "Done" });
expect(toastRecords[0].open).toBe(true);
+6 -2
View File
@@ -80,7 +80,10 @@ if (typeof document !== "undefined") {
// ── Public dismiss-function type ─────────────────────────────────────────────
type DismissFn = (replacement?: Pick<ToastOptions, "variant" | "message" | "title">) => void;
/** Closes the toast; `id` is its record's id, the same one a collapsed duplicate returns. */
type DismissFn = ((replacement?: Pick<ToastOptions, "variant" | "message" | "title">) => void) & {
readonly id: string;
};
// ── Core toast() function ────────────────────────────────────────────────────
@@ -188,7 +191,7 @@ function updateToast(
}
function makeDismissFn(id: string, position: ToastPosition): DismissFn {
return (replacement) => {
const close = (replacement?: Pick<ToastOptions, "variant" | "message" | "title">) => {
dismiss(id);
if (replacement) {
toast({
@@ -199,6 +202,7 @@ function makeDismissFn(id: string, position: ToastPosition): DismissFn {
});
}
};
return Object.assign(close, { id });
}
function dismiss(id: string): void {
@@ -153,4 +153,22 @@ describe("OPopover", () => {
content.vm.$emit("open-auto-focus", event);
expect(wrapper.emitted("open-auto-focus")?.[0]).toEqual([event]);
});
it("forwards outside pointerdown and focus so a consumer can keep it open for a panel it owns", () => {
const wrapper = mount(OPopover, {
props: { open: true },
slots: {
trigger: "<button>Open</button>",
default: '<div data-testid="content">Body</div>',
},
});
const content = wrapper.findAllComponents(PopoverContent).at(-1)!;
const down = new Event("pointerdown", { cancelable: true });
const focus = new Event("focusin", { cancelable: true });
content.vm.$emit("pointer-down-outside", down);
content.vm.$emit("focus-outside", focus);
expect(wrapper.emitted("pointer-down-outside")?.[0]).toEqual([down]);
expect(wrapper.emitted("focus-outside")?.[0]).toEqual([focus]);
expect(down.defaultPrevented).toBe(false);
});
});
+6
View File
@@ -84,6 +84,10 @@ const emit = defineEmits<{
"update:open": [boolean];
/** Forwards reka's initial-focus event so a consumer can redirect it (call `preventDefault()` to opt out of the default first-tabbable focus). */
"open-auto-focus": [Event];
/** Forwards reka's outside pointerdown; `preventDefault()` keeps the popover open, e.g. for a panel it owns outside its content. */
"pointer-down-outside": [Event];
/** Forwards reka's outside focus; `preventDefault()` keeps the popover open. */
"focus-outside": [Event];
}>();
// Vue boolean-casts an absent `open` prop to `false`, which would lock
@@ -175,11 +179,13 @@ function handlePointerDownOutside(event: Event) {
if (nestedOverlayCount.value > 0 || withinNestedCloseGrace()) {
event.preventDefault();
}
emit("pointer-down-outside", event);
}
function handleFocusOutside(event: Event) {
if (nestedOverlayCount.value > 0 || withinNestedCloseGrace()) {
event.preventDefault();
}
emit("focus-outside", event);
}
// Close on ancestor scroll so the portal never floats detached from its trigger.
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "إيقاف التشغيل التلقائي",
"autoRefreshWithValue": "تحديث تلقائي: {value}",
"searchHistory": "سجل البحث",
"usageCostHistory": "سجل الاستخدام والتكلفة",
"createScheduledSearch": "إنشاء البحث المجدول",
"listScheduledSearch": "البحث المجدول في القائمة",
"scheduleSearchJob": "جدولة مهمة بحث",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "يرجى اختيار خيار الكشف عند"
},
"search_history": {
"title": "سجل البحث",
"title": "سجل الاستخدام والتكلفة",
"get_history": "احصل على التاريخ",
"executed_at": "تم إعدامه في",
"sql_query": "SQL استعلام",
"delayMessage": "قد يتأخر سجل البحث بمقدار"
"delayMessage": "قد يتأخر سجل الاستخدام والتكلفة بمقدار"
},
"correlation": {
"unstableDimensionNote": "بعد غير مستقر - تغييرات عند إعادة تشغيل الكبسولة. الافتراضي: جميع القيم.",
@@ -20562,7 +20563,7 @@
"nodes": "العقد",
"modelPricing": "تسعير النماذج",
"searchSchedulers": "مُجدولات البحث",
"searchHistory": "سجل البحث",
"searchHistory": "سجل الاستخدام والتكلفة",
"sessions": "الجلسات",
"evalTemplates": "التقييمات الإلكترونية — القوالب",
"scorers": "التقييمات عبر الإنترنت — أدوات التقييم",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "تحديث القائمة",
"alertTemplatesAdd": "إضافة قالب جديد",
"alertTemplatesRefresh": "تحديث القائمة",
"logsSearchHistory": "فتح سجل البحث",
"logsSearchHistory": "افتح سجل الاستخدام والتكلفة",
"alertsImport": "استيراد تنبيه",
"panelEditorRun": "تشغيل / تطبيق استعلام",
"panelEditorSave": "حفظ ومتابعة التحرير",
@@ -28100,5 +28101,71 @@
"legend": "حالة خط المعالجة",
"legendToggle": "إظهار هذه الحالة على الخريطة أو إخفاؤها"
}
},
"recentSearches": {
"title": "عمليات البحث الأخيرة",
"aboutKeptDays": "عمليات بحثك فقط في {org}، مرئية لك وحدك. تُحفظ لمدة {days} يوم؛ تبقى عمليات البحث المميزة بنجمة حتى تزيلها. | عمليات بحثك فقط في {org}، مرئية لك وحدك. تُحفظ لمدة {days} أيام؛ تبقى عمليات البحث المميزة بنجمة حتى تزيلها.",
"aboutKeptForever": "عمليات بحثك فقط في {org}، مرئية لك وحدك. تُحفظ حتى تُحذف.",
"pageTitle": {
"logs": "عمليات البحث الأخيرة في السجلات",
"traces": "عمليات البحث الأخيرة في التتبعات",
"metrics": "عمليات البحث الأخيرة في المقاييس",
"dbm": "عمليات البحث الأخيرة",
"profiles": "عمليات البحث الأخيرة"
},
"page": {
"logs": "السجلات",
"traces": "التتبعات",
"metrics": "المقاييس",
"dbm": "قاعدة البيانات",
"profiles": "ملفات تعريف الأداء"
},
"allPages": "كل الصفحات",
"viewAll": "اعرض الكل",
"failedCount": "{count} فشل",
"keyHints": "↑↓ للتنقل · PgUp/PgDn لتمرير الاستعلام · Enter للتشغيل · Shift+Enter للتحميل فقط · Esc للإغلاق",
"loadToFix": "حمّل للإصلاح",
"emptyTitle": "لا توجد عمليات بحث أخيرة بعد",
"loadError": "تعذر تحميل عمليات البحث الأخيرة",
"retry": "أعد المحاولة",
"warning": "اكتمل مع تحذير: انتهت المهلة أو كانت النتائج جزئية",
"failed": "فشل",
"ranCount": "شُغّل {count} مرة",
"lineCount": "{count} سطر | {count} أسطر",
"relativeWindow": "آخر {period}",
"rerunNow": "أعد التشغيل الآن",
"rerunOriginal": "في الوقت الأصلي",
"loadOnly": "حمّل دون تشغيل",
"copyLink": "انسخ الرابط",
"copyQuery": "انسخ الاستعلام",
"star": "أضف نجمة",
"unstar": "أزل النجمة",
"linkCopied": "تم نسخ الرابط",
"queryCopied": "تم نسخ الاستعلام",
"copyFunction": "انسخ الدالة",
"functionCopied": "تم نسخ الدالة",
"functionLabel": "الدالة (VRL)",
"searchPlaceholder": "ابحث في الاستعلامات والتدفقات",
"allStreams": "كل التدفقات",
"statusAll": "أي حالة",
"statusOk": "نجح",
"statusError": "فشل",
"starredOnly": "المميزة بنجمة فقط",
"datePlaceholder": "dd/mm/yyyy",
"filterByDateRun": "الفلترة حسب تاريخ التشغيل",
"today": "اليوم",
"yesterday": "أمس",
"noMatch": "لا توجد عمليات بحث تطابق هذه الفلاتر",
"reset": "إعادة تعيين الكل",
"resetFilters": "إعادة تعيين الفلاتر",
"loadMore": "تحميل المزيد",
"clear": "مسح سجلي",
"confirmClear": "مسح السجل",
"clearBodyPage": "سيؤدي هذا إلى إزالة بحث {page} الأخير لك (العدد: {count}) في {org}. لن تتأثر العروض المحفوظة. | سيؤدي هذا إلى إزالة {count} من عمليات بحث {page} الأخيرة لك في {org}. لن تتأثر العروض المحفوظة.",
"clearBodyAll": "سيؤدي هذا إلى إزالة بحثك الأخير (العدد: {count}) في {org} عبر السجلات والتتبعات والمقاييس. لن تتأثر العروض المحفوظة. | سيؤدي هذا إلى إزالة {count} من عمليات البحث الأخيرة لك في {org} عبر السجلات والتتبعات والمقاييس. لن تتأثر العروض المحفوظة.",
"includeStarred": "إزالة {count} من بحثي المميز أيضًا | إزالة {count} من عمليات بحثي المميزة أيضًا",
"recentRow": "بحث حديث",
"deletedQuery": "تم حذف {query}",
"undo": "تراجع"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "Auto Run deaktivieren",
"autoRefreshWithValue": "Automatische Aktualisierung: {value}",
"searchHistory": "Suchverlauf",
"usageCostHistory": "Nutzungs- und Kostenverlauf",
"createScheduledSearch": "Geplante Suche erstellen",
"listScheduledSearch": "Geplante Suche auflisten",
"scheduleSearchJob": "Jobsuche planen",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "Bitte wählen Sie eine Option für „Erkennen bei“"
},
"search_history": {
"title": "Suchverlauf",
"title": "Nutzungs- und Kostenverlauf",
"get_history": "Verlauf abrufen",
"executed_at": "Ausgeführt am",
"sql_query": "SQL-Abfrage",
"delayMessage": "Der Suchverlauf könnte sich verzögern um"
"delayMessage": "Der Nutzungs- und Kostenverlauf kann sich verzögern um"
},
"correlation": {
"unstableDimensionNote": "Instabile Dimension - Änderungen beim Pod-Neustart. Standard: Alle Werte.",
@@ -20562,7 +20563,7 @@
"nodes": "Knoten",
"modelPricing": "Preismodell",
"searchSchedulers": "Scheduler suchen",
"searchHistory": "Suchverlauf",
"searchHistory": "Nutzungs- und Kostenverlauf",
"sessions": "Sessions",
"evalTemplates": "Online-Evals — Vorlagen",
"scorers": "Online-Evals — Scorer",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "Liste aktualisieren",
"alertTemplatesAdd": "Neue Vorlage hinzufügen",
"alertTemplatesRefresh": "Liste aktualisieren",
"logsSearchHistory": "Suchverlauf öffnen",
"logsSearchHistory": "Nutzungs- und Kostenverlauf öffnen",
"alertsImport": "Alarm importieren",
"panelEditorRun": "Abfrage ausführen/anwenden",
"panelEditorSave": "Speichern und weiter bearbeiten",
@@ -28100,5 +28101,71 @@
"legend": "Pipeline-Status",
"legendToggle": "Diesen Status auf der Karte ein- oder ausblenden"
}
},
"recentSearches": {
"title": "Letzte Suchen",
"aboutKeptDays": "Nur Ihre Suchen in {org}, nur für Sie sichtbar. Sie werden {days} Tag aufbewahrt; mit Stern markierte Suchen bleiben, bis Sie sie entfernen. | Nur Ihre Suchen in {org}, nur für Sie sichtbar. Sie werden {days} Tage aufbewahrt; mit Stern markierte Suchen bleiben, bis Sie sie entfernen.",
"aboutKeptForever": "Nur Ihre Suchen in {org}, nur für Sie sichtbar. Sie bleiben bis zur Löschung erhalten.",
"pageTitle": {
"logs": "Letzte Log-Suchen",
"traces": "Letzte Trace-Suchen",
"metrics": "Letzte Metriken-Suchen",
"dbm": "Letzte Suchen",
"profiles": "Letzte Suchen"
},
"page": {
"logs": "Logs",
"traces": "Traces",
"metrics": "Metriken",
"dbm": "Datenbank",
"profiles": "Profile"
},
"allPages": "Alle Seiten",
"viewAll": "Alle anzeigen",
"failedCount": "{count} fehlgeschlagen",
"keyHints": "↑↓ navigieren · PgUp/PgDn Abfrage scrollen · Enter ausführen · Shift+Enter nur laden · Esc schließen",
"loadToFix": "Zum Beheben laden",
"emptyTitle": "Noch keine Suchen vorhanden",
"loadError": "Letzte Suchen konnten nicht geladen werden",
"retry": "Erneut versuchen",
"warning": "Mit einer Warnung beendet: Zeitüberschreitung oder Teilergebnisse",
"failed": "Fehlgeschlagen",
"ranCount": "{count}x ausgeführt",
"lineCount": "{count} Zeile | {count} Zeilen",
"relativeWindow": "Letzte {period}",
"rerunNow": "Jetzt erneut ausführen",
"rerunOriginal": "Zum ursprünglichen Zeitpunkt",
"loadOnly": "Laden ohne Ausführen",
"copyLink": "Link kopieren",
"copyQuery": "Abfrage kopieren",
"star": "Mit Stern markieren",
"unstar": "Stern entfernen",
"linkCopied": "Link kopiert",
"queryCopied": "Abfrage kopiert",
"copyFunction": "Funktion kopieren",
"functionCopied": "Funktion kopiert",
"functionLabel": "Funktion (VRL)",
"searchPlaceholder": "Abfragen und Streams durchsuchen",
"allStreams": "Alle Streams",
"statusAll": "Beliebiger Status",
"statusOk": "Erfolgreich",
"statusError": "Fehlgeschlagen",
"starredOnly": "Nur mit Stern",
"datePlaceholder": "TT/MM/JJJJ",
"filterByDateRun": "Nach Ausführungsdatum filtern",
"today": "Heute",
"yesterday": "Gestern",
"noMatch": "Keine Suchanfragen entsprechen diesen Filtern",
"reset": "Alle zurücksetzen",
"resetFilters": "Filter zurücksetzen",
"loadMore": "Mehr laden",
"clear": "Meinen Verlauf löschen",
"confirmClear": "Verlauf löschen",
"clearBodyPage": "Hiermit wird Ihre {count} letzte {page}-Suche in {org} entfernt. Gespeicherte Ansichten sind nicht betroffen. | Hiermit werden Ihre {count} letzten {page}-Suchen in {org} entfernt. Gespeicherte Ansichten sind nicht betroffen.",
"clearBodyAll": "Hiermit wird Ihre {count} letzte Suche in {org} über Logs, Traces und Metriken entfernt. Gespeicherte Ansichten sind nicht betroffen. | Hiermit werden Ihre {count} letzten Suchen in {org} über Logs, Traces und Metriken entfernt. Gespeicherte Ansichten sind nicht betroffen.",
"includeStarred": "Auch meine {count} mit Stern markierte Suche entfernen | Auch meine {count} mit Stern markierten Suchen entfernen",
"recentRow": "Letzte Suche",
"deletedQuery": "Gelöscht: {query}",
"undo": "Rückgängig"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "Turn off Auto Run",
"autoRefreshWithValue": "Auto Refresh: {value}",
"searchHistory": "Search History",
"usageCostHistory": "Usage & cost history",
"createScheduledSearch": "Create Scheduled Search",
"listScheduledSearch": "List Scheduled Search",
"scheduleSearchJob": "Schedule Search Job",
@@ -15358,11 +15359,11 @@
"select_detect_at_option": "Please select detect at option"
},
"search_history": {
"title": "Search History",
"title": "Usage & cost history",
"get_history": "Get History",
"executed_at": "Executed At",
"sql_query": "SQL Query",
"delayMessage": "Search History might be delayed by"
"delayMessage": "Usage & cost history might be delayed by"
},
"correlation": {
"unstableDimensionNote": "Unstable dimension - changes on pod restart. Default: All values.",
@@ -20560,7 +20561,7 @@
"nodes": "Nodes",
"modelPricing": "Model Pricing",
"searchSchedulers": "Search Schedulers",
"searchHistory": "Search History",
"searchHistory": "Usage & cost history",
"sessions": "Sessions",
"evalTemplates": "Online Evals — Templates",
"scorers": "Online Evals — Scorers",
@@ -20648,7 +20649,7 @@
"alertSourcesRefresh": "Refresh list",
"alertTemplatesAdd": "Add new template",
"alertTemplatesRefresh": "Refresh list",
"logsSearchHistory": "Open search history",
"logsSearchHistory": "Open usage & cost history",
"alertsImport": "Import alert",
"panelEditorRun": "Run / apply query",
"panelEditorSave": "Save and keep editing",
@@ -28098,5 +28099,71 @@
"legend": "Pipeline status",
"legendToggle": "Show or hide this status on the map"
}
},
"recentSearches": {
"title": "Recent searches",
"aboutKeptDays": "Only your searches in {org}, visible only to you. Kept {days} day; starred searches stay until you remove them. | Only your searches in {org}, visible only to you. Kept {days} days; starred searches stay until you remove them.",
"aboutKeptForever": "Only your searches in {org}, visible only to you. Kept until deleted.",
"pageTitle": {
"logs": "Recent logs searches",
"traces": "Recent traces searches",
"metrics": "Recent metrics searches",
"dbm": "Recent searches",
"profiles": "Recent searches"
},
"page": {
"logs": "Logs",
"traces": "Traces",
"metrics": "Metrics",
"dbm": "Database",
"profiles": "Profiles"
},
"allPages": "All pages",
"viewAll": "View all",
"failedCount": "{count} failed",
"keyHints": "↑↓ move · PgUp/PgDn scroll query · Enter run · Shift+Enter load only · Esc close",
"loadToFix": "Load to fix",
"emptyTitle": "No recent searches yet",
"loadError": "Couldn't load recent searches",
"retry": "Retry",
"warning": "Finished with a warning: timed out or partial results",
"failed": "Failed",
"ranCount": "ran {count}x",
"lineCount": "{count} line | {count} lines",
"relativeWindow": "Last {period}",
"rerunNow": "Re-run now",
"rerunOriginal": "At original time",
"loadOnly": "Load without running",
"copyLink": "Copy link",
"copyQuery": "Copy query",
"star": "Star",
"unstar": "Unstar",
"linkCopied": "Link copied",
"queryCopied": "Query copied",
"copyFunction": "Copy function",
"functionCopied": "Function copied",
"functionLabel": "Function (VRL)",
"searchPlaceholder": "Search queries and streams",
"allStreams": "All streams",
"statusAll": "Any status",
"statusOk": "Succeeded",
"statusError": "Failed",
"starredOnly": "Starred only",
"datePlaceholder": "dd/mm/yyyy",
"filterByDateRun": "Filter by date run",
"today": "Today",
"yesterday": "Yesterday",
"noMatch": "No searches match these filters",
"reset": "Reset all",
"resetFilters": "Reset filters",
"loadMore": "Load more",
"clear": "Clear my history",
"confirmClear": "Clear history",
"clearBodyPage": "This removes your {count} recent {page} search in {org}. Saved views are not affected. | This removes your {count} recent {page} searches in {org}. Saved views are not affected.",
"clearBodyAll": "This removes your {count} recent search in {org} across Logs, Traces and Metrics. Saved views are not affected. | This removes your {count} recent searches in {org} across Logs, Traces and Metrics. Saved views are not affected.",
"includeStarred": "Also remove my {count} starred search | Also remove my {count} starred searches",
"recentRow": "Recent search",
"deletedQuery": "Deleted {query}",
"undo": "Undo"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "Desactivar el modo Live",
"autoRefreshWithValue": "Actualización automática: {value}",
"searchHistory": "Historial de búsqueda",
"usageCostHistory": "Historial de uso y coste",
"createScheduledSearch": "Crear búsqueda programada",
"listScheduledSearch": "Listar búsqueda programada",
"scheduleSearchJob": "Programar trabajo de búsqueda",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "Selecciona una opción de detección"
},
"search_history": {
"title": "Historial de búsqueda",
"title": "Historial de uso y coste",
"get_history": "Obtener historial",
"executed_at": "Ejecutado en",
"sql_query": "Consulta SQL",
"delayMessage": "Es posible que el historial de búsqueda se retrase un"
"delayMessage": "El historial de uso y coste puede retrasarse hasta"
},
"correlation": {
"unstableDimensionNote": "Dimensión inestable - cambios al reiniciar el pod. Predeterminado: Todos los valores.",
@@ -20562,7 +20563,7 @@
"nodes": "Nodos",
"modelPricing": "Precios de modelos",
"searchSchedulers": "Planificadores de búsquedas",
"searchHistory": "Historial de búsqueda",
"searchHistory": "Historial de uso y coste",
"sessions": "Sesiones",
"evalTemplates": "Evaluaciones en línea — Plantillas",
"scorers": "Evaluaciones en línea — Evaluadores",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "Actualizar lista",
"alertTemplatesAdd": "Añadir nueva plantilla",
"alertTemplatesRefresh": "Actualizar lista",
"logsSearchHistory": "Abrir historial de búsqueda",
"logsSearchHistory": "Abrir el historial de uso y coste",
"alertsImport": "Importar alerta",
"panelEditorRun": "Ejecutar/aplicar consulta",
"panelEditorSave": "Guardar y seguir editando",
@@ -28100,5 +28101,71 @@
"legend": "Estado del pipeline",
"legendToggle": "Mostrar u ocultar este estado en el mapa"
}
},
"recentSearches": {
"title": "Búsquedas recientes",
"aboutKeptDays": "Solo tus búsquedas en {org}, visibles solo para ti. Se conservan {days} día; las búsquedas destacadas permanecen hasta que las elimines. | Solo tus búsquedas en {org}, visibles solo para ti. Se conservan {days} días; las búsquedas destacadas permanecen hasta que las elimines.",
"aboutKeptForever": "Solo tus búsquedas en {org}, visibles solo para ti. Se conservan hasta que se eliminen.",
"pageTitle": {
"logs": "Búsquedas recientes en logs",
"traces": "Búsquedas recientes en trazas",
"metrics": "Búsquedas recientes en métricas",
"dbm": "Búsquedas recientes",
"profiles": "Búsquedas recientes"
},
"page": {
"logs": "Logs",
"traces": "Trazas",
"metrics": "Métricas",
"dbm": "Base de datos",
"profiles": "Perfiles"
},
"allPages": "Todas las páginas",
"viewAll": "Ver todo",
"failedCount": "{count} fallidas",
"keyHints": "↑↓ mover · PgUp/PgDn desplazar consulta · Enter ejecutar · Mayús+Enter solo cargar · Esc cerrar",
"loadToFix": "Cargar para corregir",
"emptyTitle": "Aún no hay búsquedas recientes",
"loadError": "No se pudieron cargar las búsquedas recientes",
"retry": "Reintentar",
"warning": "Finalizó con una advertencia: tiempo de espera agotado o resultados parciales",
"failed": "Fallida",
"ranCount": "ejecutada {count} veces",
"lineCount": "{count} línea | {count} líneas",
"relativeWindow": "Último {period}",
"rerunNow": "Volver a ejecutar ahora",
"rerunOriginal": "En el momento original",
"loadOnly": "Cargar sin ejecutar",
"copyLink": "Copiar enlace",
"copyQuery": "Copiar consulta",
"star": "Destacar",
"unstar": "Quitar destacado",
"linkCopied": "Enlace copiado",
"queryCopied": "Consulta copiada",
"copyFunction": "Copiar función",
"functionCopied": "Función copiada",
"functionLabel": "Función (VRL)",
"searchPlaceholder": "Buscar consultas y streams",
"allStreams": "Todos los streams",
"statusAll": "Cualquier estado",
"statusOk": "Correcta",
"statusError": "Fallida",
"starredOnly": "Solo destacadas",
"datePlaceholder": "dd/mm/aaaa",
"filterByDateRun": "Filtrar por fecha de ejecución",
"today": "Hoy",
"yesterday": "Ayer",
"noMatch": "Ninguna búsqueda coincide con estos filtros",
"reset": "Restablecer todo",
"resetFilters": "Restablecer filtros",
"loadMore": "Cargar más",
"clear": "Borrar mi historial",
"confirmClear": "Borrar historial",
"clearBodyPage": "Esto elimina tu {count} búsqueda reciente en {page} en {org}. Las vistas guardadas no se verán afectadas. | Esto elimina tus {count} búsquedas recientes en {page} en {org}. Las vistas guardadas no se verán afectadas.",
"clearBodyAll": "Esto elimina tu {count} búsqueda reciente en {org} en Logs, Trazas y Métricas. Las vistas guardadas no se verán afectadas. | Esto elimina tus {count} búsquedas recientes en {org} en Logs, Trazas y Métricas. Las vistas guardadas no se verán afectadas.",
"includeStarred": "También eliminar mi {count} búsqueda favorita | También eliminar mis {count} búsquedas favoritas",
"recentRow": "Búsqueda reciente",
"deletedQuery": "Se eliminó {query}",
"undo": "Deshacer"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "Désactiver le mode Live",
"autoRefreshWithValue": "Actualisation automatique : {value}",
"searchHistory": "Historique des recherches",
"usageCostHistory": "Historique d'utilisation et de coûts",
"createScheduledSearch": "Créer une recherche planifiée",
"listScheduledSearch": "Liste des recherches planifiées",
"scheduleSearchJob": "Planifier une recherche d'emploi",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "Veuillez sélectionner une option de détection"
},
"search_history": {
"title": "Historique des recherches",
"title": "Historique d'utilisation et de coûts",
"get_history": "Obtenir l'historique",
"executed_at": "Exécuté à",
"sql_query": "Requête SQL",
"delayMessage": "L'historique des recherches peut être retardé par"
"delayMessage": "L'historique d'utilisation et de coûts peut être retardé de"
},
"correlation": {
"unstableDimensionNote": "Dimension instable - change au redémarrage du pod. Par défaut : toutes les valeurs.",
@@ -20562,7 +20563,7 @@
"nodes": "Nœuds",
"modelPricing": "Tarification des modèles",
"searchSchedulers": "Planificateurs de recherche",
"searchHistory": "Historique des recherches",
"searchHistory": "Historique d'utilisation et de coûts",
"sessions": "Séances",
"evalTemplates": "Evaluations en ligne — Modèles",
"scorers": "Évaluations en ligne — Évaluateurs",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "Actualiser la liste",
"alertTemplatesAdd": "Ajouter un nouveau modèle",
"alertTemplatesRefresh": "Actualiser la liste",
"logsSearchHistory": "Ouvrir l'historique des recherches",
"logsSearchHistory": "Ouvrir l'historique d'utilisation et de coûts",
"alertsImport": "Importer une alerte",
"panelEditorRun": "Exécuter/appliquer une requête",
"panelEditorSave": "Enregistrer et continuer la modification",
@@ -28100,5 +28101,71 @@
"legend": "Statut des pipelines",
"legendToggle": "Afficher ou masquer ce statut sur la carte"
}
},
"recentSearches": {
"title": "Recherches récentes",
"aboutKeptDays": "Uniquement vos recherches dans {org}, visibles uniquement par vous. Conservées pendant {days} jour ; les recherches favorites restent jusqu'à leur suppression. | Uniquement vos recherches dans {org}, visibles uniquement par vous. Conservées pendant {days} jours ; les recherches favorites restent jusqu'à leur suppression.",
"aboutKeptForever": "Uniquement vos recherches dans {org}, visibles uniquement par vous. Conservées jusqu'à leur suppression.",
"pageTitle": {
"logs": "Recherches récentes dans les logs",
"traces": "Recherches récentes dans les traces",
"metrics": "Recherches récentes dans les métriques",
"dbm": "Recherches récentes",
"profiles": "Recherches récentes"
},
"page": {
"logs": "Logs",
"traces": "Traces",
"metrics": "Métriques",
"dbm": "Base de données",
"profiles": "Profils"
},
"allPages": "Toutes les pages",
"viewAll": "Voir tout",
"failedCount": "{count} en échec",
"keyHints": "↑↓ déplacer · PgUp/PgDn faire défiler la requête · Entrée exécuter · Maj+Entrée charger uniquement · Échap fermer",
"loadToFix": "Charger pour corriger",
"emptyTitle": "Aucune recherche récente pour l'instant",
"loadError": "Impossible de charger les recherches récentes",
"retry": "Réessayer",
"warning": "Terminé avec un avertissement : délai dépassé ou résultats partiels",
"failed": "Échec",
"ranCount": "exécutée {count} fois",
"lineCount": "{count} ligne | {count} lignes",
"relativeWindow": "Dernières {period}",
"rerunNow": "Relancer maintenant",
"rerunOriginal": "À l'heure d'origine",
"loadOnly": "Charger sans exécuter",
"copyLink": "Copier le lien",
"copyQuery": "Copier la requête",
"star": "Ajouter aux favoris",
"unstar": "Retirer des favoris",
"linkCopied": "Lien copié",
"queryCopied": "Requête copiée",
"copyFunction": "Copier la fonction",
"functionCopied": "Fonction copiée",
"functionLabel": "Fonction (VRL)",
"searchPlaceholder": "Rechercher des requêtes et des flux",
"allStreams": "Tous les flux",
"statusAll": "Tous les statuts",
"statusOk": "Succès",
"statusError": "Échec",
"starredOnly": "Favoris uniquement",
"datePlaceholder": "jj/mm/aaaa",
"filterByDateRun": "Filtrer par date d'exécution",
"today": "Aujourd'hui",
"yesterday": "Hier",
"noMatch": "Aucune recherche ne correspond à ces filtres",
"reset": "Tout réinitialiser",
"resetFilters": "Réinitialiser les filtres",
"loadMore": "Charger plus",
"clear": "Effacer mon historique",
"confirmClear": "Effacer l'historique",
"clearBodyPage": "Cela supprime {count} recherche récente dans {page} de votre historique pour {org}. Les vues enregistrées ne sont pas affectées. | Cela supprime {count} recherches récentes dans {page} de votre historique pour {org}. Les vues enregistrées ne sont pas affectées.",
"clearBodyAll": "Cela supprime {count} recherche récente de votre historique dans {org}, sur Logs, Traces et Métriques. Les vues enregistrées ne sont pas affectées. | Cela supprime {count} recherches récentes de votre historique dans {org}, sur Logs, Traces et Métriques. Les vues enregistrées ne sont pas affectées.",
"includeStarred": "Supprimer aussi ma recherche favorite ({count}) | Supprimer aussi mes {count} recherches favorites",
"recentRow": "Recherche récente",
"deletedQuery": "Recherche supprimée : {query}",
"undo": "Annuler"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "Disattiva la modalità Live",
"autoRefreshWithValue": "Aggiornamento automatico: {value}",
"searchHistory": "Cronologia delle ricerche",
"usageCostHistory": "Cronologia utilizzo e costi",
"createScheduledSearch": "Crea ricerca pianificata",
"listScheduledSearch": "Elenca ricerca pianificata",
"scheduleSearchJob": "Pianifica job di ricerca",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "Seleziona l'opzione di rilevamento"
},
"search_history": {
"title": "Cronologia delle ricerche",
"title": "Cronologia utilizzo e costi",
"get_history": "Ottieni la cronologia",
"executed_at": "Eseguito a",
"sql_query": "Query SQL",
"delayMessage": "La cronologia delle ricerche potrebbe essere ritardata"
"delayMessage": "La cronologia utilizzo e costi potrebbe essere ritardata di"
},
"correlation": {
"unstableDimensionNote": "Dimensione instabile - cambia al riavvio del pod. Predefinito: tutti i valori.",
@@ -20562,7 +20563,7 @@
"nodes": "Nodi",
"modelPricing": "Prezzi del modello",
"searchSchedulers": "Pianificatori di ricerca",
"searchHistory": "Cronologia delle ricerche",
"searchHistory": "Cronologia utilizzo e costi",
"sessions": "Sessioni",
"evalTemplates": "Valutazioni online — Template",
"scorers": "Valutazioni online — Valutatori",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "Aggiorna elenco",
"alertTemplatesAdd": "Aggiungi nuovo template",
"alertTemplatesRefresh": "Aggiorna elenco",
"logsSearchHistory": "Apri la cronologia delle ricerche",
"logsSearchHistory": "Apri cronologia utilizzo e costi",
"alertsImport": "Importa avviso",
"panelEditorRun": "Esegui/applica query",
"panelEditorSave": "Salva e continua a modificare",
@@ -28100,5 +28101,71 @@
"legend": "Stato della pipeline",
"legendToggle": "Mostra o nascondi questo stato sulla mappa"
}
},
"recentSearches": {
"title": "Ricerche recenti",
"aboutKeptDays": "Solo le tue ricerche in {org}, visibili solo a te. Conservate per {days} giorno; le ricerche con stella restano finché non le rimuovi. | Solo le tue ricerche in {org}, visibili solo a te. Conservate per {days} giorni; le ricerche con stella restano finché non le rimuovi.",
"aboutKeptForever": "Solo le tue ricerche in {org}, visibili solo a te. Conservate fino all'eliminazione.",
"pageTitle": {
"logs": "Ricerche log recenti",
"traces": "Ricerche tracce recenti",
"metrics": "Ricerche metriche recenti",
"dbm": "Ricerche recenti",
"profiles": "Ricerche recenti"
},
"page": {
"logs": "Log",
"traces": "Tracce",
"metrics": "Metriche",
"dbm": "Database",
"profiles": "Profili"
},
"allPages": "Tutte le pagine",
"viewAll": "Visualizza tutto",
"failedCount": "{count} non riuscite",
"keyHints": "↑↓ sposta · PgUp/PgDn scorri query · Invio esegui · Shift+Invio solo carica · Esc chiudi",
"loadToFix": "Carica per correggere",
"emptyTitle": "Nessuna ricerca recente",
"loadError": "Impossibile caricare le ricerche recenti",
"retry": "Riprova",
"warning": "Terminata con un avviso: timeout o risultati parziali",
"failed": "Non riuscita",
"ranCount": "eseguita {count}x",
"lineCount": "{count} riga | {count} righe",
"relativeWindow": "Ultimi {period}",
"rerunNow": "Riesegui ora",
"rerunOriginal": "All'ora originale",
"loadOnly": "Carica senza eseguire",
"copyLink": "Copia link",
"copyQuery": "Copia query",
"star": "Aggiungi stella",
"unstar": "Rimuovi stella",
"linkCopied": "Link copiato",
"queryCopied": "Query copiata",
"copyFunction": "Copia funzione",
"functionCopied": "Funzione copiata",
"functionLabel": "Funzione (VRL)",
"searchPlaceholder": "Cerca query e stream",
"allStreams": "Tutti gli stream",
"statusAll": "Qualsiasi stato",
"statusOk": "Riuscita",
"statusError": "Non riuscita",
"starredOnly": "Solo con stella",
"datePlaceholder": "gg/mm/aaaa",
"filterByDateRun": "Filtra per data di esecuzione",
"today": "Oggi",
"yesterday": "Ieri",
"noMatch": "Nessuna ricerca corrisponde a questi filtri",
"reset": "Reimposta tutto",
"resetFilters": "Reimposta filtri",
"loadMore": "Carica altro",
"clear": "Cancella la mia cronologia",
"confirmClear": "Cancella cronologia",
"clearBodyPage": "Verrà rimossa la tua ricerca recente ({count}) in {page} in {org}. Le viste salvate non sono interessate. | Verranno rimosse le tue {count} ricerche recenti in {page} in {org}. Le viste salvate non sono interessate.",
"clearBodyAll": "Verrà rimossa la tua ricerca recente ({count}) in {org} tra Log, Tracce e Metriche. Le viste salvate non sono interessate. | Verranno rimosse le tue {count} ricerche recenti in {org} tra Log, Tracce e Metriche. Le viste salvate non sono interessate.",
"includeStarred": "Rimuovi anche la mia ricerca preferita ({count}) | Rimuovi anche le mie {count} ricerche preferite",
"recentRow": "Ricerca recente",
"deletedQuery": "Eliminata {query}",
"undo": "Annulla"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "ライブモードをオフにする",
"autoRefreshWithValue": "自動更新:{value}",
"searchHistory": "検索履歴",
"usageCostHistory": "使用量とコストの履歴",
"createScheduledSearch": "スケジュール検索の作成",
"listScheduledSearch": "スケジュール検索を一覧表示",
"scheduleSearchJob": "検索ジョブをスケジュール",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "検出タイミングのオプションを選択してください"
},
"search_history": {
"title": "検索履歴",
"title": "使用量とコストの履歴",
"get_history": "履歴を取得",
"executed_at": "実行日",
"sql_query": "SQL クエリ",
"delayMessage": "検索履歴は次の理由で遅延する可能性があります"
"delayMessage": "使用量とコストの履歴は遅延する可能性があります"
},
"correlation": {
"unstableDimensionNote": "不安定なディメンション - ポッド再起動時に変更されます。デフォルト: すべての値。",
@@ -20562,7 +20563,7 @@
"nodes": "ノード",
"modelPricing": "モデル価格",
"searchSchedulers": "検索スケジューラー",
"searchHistory": "検索履歴",
"searchHistory": "使用量とコストの履歴",
"sessions": "セッション",
"evalTemplates": "オンライン評価 — テンプレート",
"scorers": "オンライン評価 — スコアラー",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "リストを更新",
"alertTemplatesAdd": "新しいテンプレートを追加",
"alertTemplatesRefresh": "リストを更新",
"logsSearchHistory": "検索履歴を開く",
"logsSearchHistory": "使用量とコストの履歴を開く",
"alertsImport": "アラートをインポート",
"panelEditorRun": "クエリの実行/適用",
"panelEditorSave": "保存して編集を続ける",
@@ -28100,5 +28101,71 @@
"legend": "パイプラインのステータス",
"legendToggle": "このステータスをマップ上で表示または非表示にする"
}
},
"recentSearches": {
"title": "最近の検索",
"aboutKeptDays": "{org} 内のあなたの検索のみが表示され、他のユーザーには見えません。{days} 日間保持されます。スターを付けた検索は削除するまで保持されます。 | {org} 内のあなたの検索のみが表示され、他のユーザーには見えません。{days} 日間保持されます。スターを付けた検索は削除するまで保持されます。",
"aboutKeptForever": "{org} 内のあなたの検索のみが表示され、他のユーザーには見えません。削除するまで保持されます。",
"pageTitle": {
"logs": "最近のログ検索",
"traces": "最近のトレース検索",
"metrics": "最近のメトリクス検索",
"dbm": "最近の検索",
"profiles": "最近の検索"
},
"page": {
"logs": "ログ",
"traces": "トレース",
"metrics": "メトリクス",
"dbm": "データベース",
"profiles": "プロファイル"
},
"allPages": "すべてのページ",
"viewAll": "すべて表示",
"failedCount": "{count} 件失敗",
"keyHints": "↑↓ 移動 · PgUp/PgDn クエリをスクロール · Enter 実行 · Shift+Enter 読み込みのみ · Esc 閉じる",
"loadToFix": "読み込んで修正",
"emptyTitle": "最近の検索はまだありません",
"loadError": "最近の検索を読み込めませんでした",
"retry": "再試行",
"warning": "警告付きで完了: タイムアウトまたは部分的な結果",
"failed": "失敗",
"ranCount": "{count} 回実行",
"lineCount": "{count} 行 | {count} 行",
"relativeWindow": "直近 {period}",
"rerunNow": "今すぐ再実行",
"rerunOriginal": "元の時刻で",
"loadOnly": "実行せずに読み込む",
"copyLink": "リンクをコピー",
"copyQuery": "クエリをコピー",
"star": "スターを付ける",
"unstar": "スターを外す",
"linkCopied": "リンクをコピーしました",
"queryCopied": "クエリをコピーしました",
"copyFunction": "関数をコピー",
"functionCopied": "関数をコピーしました",
"functionLabel": "関数 (VRL)",
"searchPlaceholder": "クエリとストリームを検索",
"allStreams": "すべてのストリーム",
"statusAll": "すべてのステータス",
"statusOk": "成功",
"statusError": "失敗",
"starredOnly": "スター付きのみ",
"datePlaceholder": "dd/mm/yyyy",
"filterByDateRun": "実行日でフィルター",
"today": "今日",
"yesterday": "昨日",
"noMatch": "これらのフィルターに一致する検索はありません",
"reset": "すべてリセット",
"resetFilters": "フィルターをリセット",
"loadMore": "さらに読み込む",
"clear": "履歴をクリア",
"confirmClear": "履歴をクリア",
"clearBodyPage": "最近の {count} 件の {page} 検索を {org} から削除します。保存済みビューには影響しません。 | 最近の {count} 件の {page} 検索を {org} から削除します。保存済みビューには影響しません。",
"clearBodyAll": "ログ、トレース、メトリクス全体にわたり、最近の {count} 件の検索を {org} から削除します。保存済みビューには影響しません。 | ログ、トレース、メトリクス全体にわたり、最近の {count} 件の検索を {org} から削除します。保存済みビューには影響しません。",
"includeStarred": "スター付き検索 {count} 件も削除する | スター付き検索 {count} 件も削除する",
"recentRow": "最近の検索",
"deletedQuery": "{query} を削除しました",
"undo": "元に戻す"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "자동 실행 끄기",
"autoRefreshWithValue": "자동 새로고침: {value}",
"searchHistory": "검색 기록",
"usageCostHistory": "사용량 및 비용 기록",
"createScheduledSearch": "예약 검색 생성",
"listScheduledSearch": "예약 검색 목록",
"scheduleSearchJob": "검색 작업 예약",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "탐지 시점 옵션을 선택하세요"
},
"search_history": {
"title": "검색 기록",
"title": "사용량 및 비용 기록",
"get_history": "기록 가져오기",
"executed_at": "실행 날짜",
"sql_query": "SQL 쿼리",
"delayMessage": "검색 기록이 다음과 같이 지연될 수 있습니다."
"delayMessage": "사용량 및 비용 기록은 다음만큼 지연될 수 있습니다"
},
"correlation": {
"unstableDimensionNote": "불안정한 차원 - 파드 재시작 시 변경됨. 기본값: 모든 값.",
@@ -20562,7 +20563,7 @@
"nodes": "노드",
"modelPricing": "모델 가격",
"searchSchedulers": "검색 스케줄러",
"searchHistory": "검색 기록",
"searchHistory": "사용량 및 비용 기록",
"sessions": "세션",
"evalTemplates": "온라인 평가 — 템플릿",
"scorers": "온라인 평가 — 스코어러",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "목록 새로고침",
"alertTemplatesAdd": "새 템플릿 추가",
"alertTemplatesRefresh": "목록 새로 고침",
"logsSearchHistory": "검색 기록 열기",
"logsSearchHistory": "사용량 및 비용 기록 열기",
"alertsImport": "알림 가져오기",
"panelEditorRun": "쿼리 실행/ 적용",
"panelEditorSave": "저장 후 계속 편집",
@@ -28100,5 +28101,71 @@
"legend": "파이프라인 상태",
"legendToggle": "맵에서 이 상태 표시 또는 숨기기"
}
},
"recentSearches": {
"title": "최근 검색",
"aboutKeptDays": "{org}에서 수행한 본인의 검색만 표시되며 본인만 볼 수 있습니다. {days}일 동안 보관되며, 별표 표시한 검색은 직접 삭제할 때까지 유지됩니다. | {org}에서 수행한 본인의 검색만 표시되며 본인만 볼 수 있습니다. {days}일 동안 보관되며, 별표 표시한 검색은 직접 삭제할 때까지 유지됩니다.",
"aboutKeptForever": "{org}에서 수행한 본인의 검색만 표시되며 본인만 볼 수 있습니다. 삭제할 때까지 보관됩니다.",
"pageTitle": {
"logs": "최근 로그 검색",
"traces": "최근 트레이스 검색",
"metrics": "최근 메트릭 검색",
"dbm": "최근 검색",
"profiles": "최근 검색"
},
"page": {
"logs": "로그",
"traces": "트레이스",
"metrics": "메트릭",
"dbm": "데이터베이스",
"profiles": "프로파일"
},
"allPages": "모든 페이지",
"viewAll": "모두 보기",
"failedCount": "{count}개 실패",
"keyHints": "↑↓ 이동 · PgUp/PgDn 쿼리 스크롤 · Enter 실행 · Shift+Enter 불러오기만 · Esc 닫기",
"loadToFix": "불러와서 수정",
"emptyTitle": "최근 검색이 없습니다",
"loadError": "최근 검색을 불러오지 못했습니다",
"retry": "다시 시도",
"warning": "경고와 함께 완료됨: 시간 초과 또는 일부 결과",
"failed": "실패",
"ranCount": "{count}회 실행",
"lineCount": "{count}줄 | {count}줄",
"relativeWindow": "최근 {period}",
"rerunNow": "지금 다시 실행",
"rerunOriginal": "원래 시각 기준",
"loadOnly": "실행하지 않고 불러오기",
"copyLink": "링크 복사",
"copyQuery": "쿼리 복사",
"star": "별표",
"unstar": "별표 해제",
"linkCopied": "링크가 복사되었습니다",
"queryCopied": "쿼리가 복사되었습니다",
"copyFunction": "함수 복사",
"functionCopied": "함수가 복사되었습니다",
"functionLabel": "함수 (VRL)",
"searchPlaceholder": "쿼리 및 스트림 검색",
"allStreams": "모든 스트림",
"statusAll": "모든 상태",
"statusOk": "성공",
"statusError": "실패",
"starredOnly": "별표만",
"datePlaceholder": "dd/mm/yyyy",
"filterByDateRun": "실행 날짜로 필터",
"today": "오늘",
"yesterday": "어제",
"noMatch": "이 필터와 일치하는 검색이 없습니다",
"reset": "모두 초기화",
"resetFilters": "필터 초기화",
"loadMore": "더 보기",
"clear": "내 기록 지우기",
"confirmClear": "기록 지우기",
"clearBodyPage": "이 작업은 {org}의 최근 {page} 검색 {count}개를 삭제합니다. 저장된 뷰는 영향을 받지 않습니다. | 이 작업은 {org}의 최근 {page} 검색 {count}개를 삭제합니다. 저장된 뷰는 영향을 받지 않습니다.",
"clearBodyAll": "이 작업은 로그, 트레이스, 메트릭 전반에 걸쳐 {org}의 최근 검색 {count}개를 삭제합니다. 저장된 뷰는 영향을 받지 않습니다. | 이 작업은 로그, 트레이스, 메트릭 전반에 걸쳐 {org}의 최근 검색 {count}개를 삭제합니다. 저장된 뷰는 영향을 받지 않습니다.",
"includeStarred": "별표 표시한 검색 {count}개도 함께 삭제 | 별표 표시한 검색 {count}개도 함께 삭제",
"recentRow": "최근 검색",
"deletedQuery": "{query} 삭제됨",
"undo": "실행 취소"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "Live-modus uitschakelen",
"autoRefreshWithValue": "Automatisch verversen: {value}",
"searchHistory": "Zoekgeschiedenis",
"usageCostHistory": "Gebruik & kostenhistorie",
"createScheduledSearch": "Geplande zoekopdracht aanmaken",
"listScheduledSearch": "Geplande zoekopdracht weergeven",
"scheduleSearchJob": "Zoektaak plannen",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "Selecteer een optie voor detecteren bij"
},
"search_history": {
"title": "Zoekgeschiedenis",
"title": "Gebruik & kostenhistorie",
"get_history": "Geschiedenis ophalen",
"executed_at": "Uitgevoerd op",
"sql_query": "SQL-zoekopdracht",
"delayMessage": "De zoekgeschiedenis kan worden vertraagd door"
"delayMessage": "Gebruik & kostenhistorie kan vertraagd zijn met"
},
"correlation": {
"unstableDimensionNote": "Instabiele dimensie - verandert bij herstart van pod. Standaard: alle waarden.",
@@ -20562,7 +20563,7 @@
"nodes": "Knopen",
"modelPricing": "Modelprijzen",
"searchSchedulers": "Planners voor zoektaken",
"searchHistory": "Zoekgeschiedenis",
"searchHistory": "Gebruik & kostenhistorie",
"sessions": "Sessies",
"evalTemplates": "Online evaluaties — Sjablonen",
"scorers": "Online evaluaties — Scorers",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "Lijst vernieuwen",
"alertTemplatesAdd": "Nieuwe sjabloon toevoegen",
"alertTemplatesRefresh": "Lijst verversen",
"logsSearchHistory": "Zoekgeschiedenis openen",
"logsSearchHistory": "Gebruik & kostenhistorie openen",
"alertsImport": "Alarm importeren",
"panelEditorRun": "Query uitvoeren/toepassen",
"panelEditorSave": "Opslaan en verder bewerken",
@@ -28100,5 +28101,71 @@
"legend": "Pipelinestatus",
"legendToggle": "Deze status op de kaart tonen of verbergen"
}
},
"recentSearches": {
"title": "Recente zoekopdrachten",
"aboutKeptDays": "Alleen jouw zoekopdrachten in {org}, alleen voor jou zichtbaar. {days} dag bewaard; zoekopdrachten met een ster blijven tot je ze verwijdert. | Alleen jouw zoekopdrachten in {org}, alleen voor jou zichtbaar. {days} dagen bewaard; zoekopdrachten met een ster blijven tot je ze verwijdert.",
"aboutKeptForever": "Alleen jouw zoekopdrachten in {org}, alleen voor jou zichtbaar. Bewaard tot je ze verwijdert.",
"pageTitle": {
"logs": "Recente zoekopdrachten in logs",
"traces": "Recente zoekopdrachten in traces",
"metrics": "Recente zoekopdrachten in metrieken",
"dbm": "Recente zoekopdrachten",
"profiles": "Recente zoekopdrachten"
},
"page": {
"logs": "Logs",
"traces": "Traces",
"metrics": "Metrieken",
"dbm": "Database",
"profiles": "Profielen"
},
"allPages": "Alle pagina's",
"viewAll": "Alles bekijken",
"failedCount": "{count} mislukt",
"keyHints": "↑↓ verplaatsen · PgUp/PgDn door query scrollen · Enter uitvoeren · Shift+Enter alleen laden · Esc sluiten",
"loadToFix": "Laden om te herstellen",
"emptyTitle": "Nog geen recente zoekopdrachten",
"loadError": "Recente zoekopdrachten konden niet worden geladen",
"retry": "Opnieuw proberen",
"warning": "Voltooid met een waarschuwing: time-out of gedeeltelijke resultaten",
"failed": "Mislukt",
"ranCount": "{count}x uitgevoerd",
"lineCount": "{count} regel | {count} regels",
"relativeWindow": "Afgelopen {period}",
"rerunNow": "Nu opnieuw uitvoeren",
"rerunOriginal": "Op oorspronkelijke tijd",
"loadOnly": "Laden zonder uitvoeren",
"copyLink": "Link kopiëren",
"copyQuery": "Query kopiëren",
"star": "Ster toevoegen",
"unstar": "Ster verwijderen",
"linkCopied": "Link gekopieerd",
"queryCopied": "Query gekopieerd",
"copyFunction": "Functie kopiëren",
"functionCopied": "Functie gekopieerd",
"functionLabel": "Functie (VRL)",
"searchPlaceholder": "Zoek in queries en streams",
"allStreams": "Alle streams",
"statusAll": "Elke status",
"statusOk": "Geslaagd",
"statusError": "Mislukt",
"starredOnly": "Alleen met ster",
"datePlaceholder": "dd/mm/yyyy",
"filterByDateRun": "Filter op uitvoeringsdatum",
"today": "Vandaag",
"yesterday": "Gisteren",
"noMatch": "Geen zoekopdrachten die aan deze filters voldoen",
"reset": "Alles resetten",
"resetFilters": "Filters resetten",
"loadMore": "Meer laden",
"clear": "Mijn geschiedenis wissen",
"confirmClear": "Geschiedenis wissen",
"clearBodyPage": "Hiermee verwijder je je {count} recente {page}-zoekopdracht in {org}. Opgeslagen weergaven worden niet beïnvloed. | Hiermee verwijder je je {count} recente {page}-zoekopdrachten in {org}. Opgeslagen weergaven worden niet beïnvloed.",
"clearBodyAll": "Hiermee verwijder je je {count} recente zoekopdracht in {org} binnen Logs, Traces en Metrics. Opgeslagen weergaven worden niet beïnvloed. | Hiermee verwijder je je {count} recente zoekopdrachten in {org} binnen Logs, Traces en Metrics. Opgeslagen weergaven worden niet beïnvloed.",
"includeStarred": "Verwijder ook mijn {count} zoekopdracht met ster | Verwijder ook mijn {count} zoekopdrachten met ster",
"recentRow": "Recente zoekopdracht",
"deletedQuery": "{query} verwijderd",
"undo": "Ongedaan maken"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "Wyłącz automatyczne uruchamianie",
"autoRefreshWithValue": "Automatyczne odświeżanie: {value}",
"searchHistory": "Historia wyszukiwania",
"usageCostHistory": "Historia użycia i kosztów",
"createScheduledSearch": "Utwórz zaplanowane wyszukiwanie",
"listScheduledSearch": "Lista zaplanowanych wyszukiwań",
"scheduleSearchJob": "Zaplanuj zadanie wyszukiwania",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "Wybierz opcję wykrywania w"
},
"search_history": {
"title": "Historia wyszukiwania",
"title": "Historia użycia i kosztów",
"get_history": "Pobierz historię",
"executed_at": "Wykonano o",
"sql_query": "Zapytanie SQL",
"delayMessage": "Historia wyszukiwania może być opóźniona o"
"delayMessage": "Historia użycia i kosztów może być opóźniona o"
},
"correlation": {
"unstableDimensionNote": "Niestabilny wymiar - zmiany po restarcie poda. Domyślnie: wszystkie wartości.",
@@ -20562,7 +20563,7 @@
"nodes": "Węzły",
"modelPricing": "Ceny modeli",
"searchSchedulers": "Harmonogramy wyszukiwania",
"searchHistory": "Historia wyszukiwania",
"searchHistory": "Historia użycia i kosztów",
"sessions": "Sesje",
"evalTemplates": "Ewaluacje online — Szablony",
"scorers": "Ewaluacje online — Ewaluatory",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "Odśwież listę",
"alertTemplatesAdd": "Dodaj nowy szablon",
"alertTemplatesRefresh": "Odśwież listę",
"logsSearchHistory": "Otwórz historię wyszukiwania",
"logsSearchHistory": "Otwórz historię użycia i kosztów",
"alertsImport": "Importuj alert",
"panelEditorRun": "Wykonaj / zastosuj zapytanie",
"panelEditorSave": "Zapisz i edytuj dalej",
@@ -28100,5 +28101,71 @@
"legend": "Status potoku",
"legendToggle": "Pokaż lub ukryj ten status na mapie"
}
},
"recentSearches": {
"title": "Ostatnie wyszukiwania",
"aboutKeptDays": "Tylko Twoje wyszukiwania w {org}, widoczne tylko dla Ciebie. Przechowywane przez {days} dzień; oznaczone gwiazdką pozostają, aż je usuniesz. | Tylko Twoje wyszukiwania w {org}, widoczne tylko dla Ciebie. Przechowywane przez {days} dni; oznaczone gwiazdką pozostają, aż je usuniesz.",
"aboutKeptForever": "Tylko Twoje wyszukiwania w {org}, widoczne tylko dla Ciebie. Przechowywane do usunięcia.",
"pageTitle": {
"logs": "Ostatnie wyszukiwania logów",
"traces": "Ostatnie wyszukiwania trace",
"metrics": "Ostatnie wyszukiwania metryk",
"dbm": "Ostatnie wyszukiwania",
"profiles": "Ostatnie wyszukiwania"
},
"page": {
"logs": "Logi",
"traces": "Trace",
"metrics": "Metryki",
"dbm": "Baza danych",
"profiles": "Profile"
},
"allPages": "Wszystkie strony",
"viewAll": "Zobacz wszystkie",
"failedCount": "{count} nieudanych",
"keyHints": "↑↓ przesuń · PgUp/PgDn przewiń zapytanie · Enter uruchom · Shift+Enter tylko wczytaj · Esc zamknij",
"loadToFix": "Wczytaj, aby poprawić",
"emptyTitle": "Brak ostatnich wyszukiwań",
"loadError": "Nie udało się wczytać ostatnich wyszukiwań",
"retry": "Ponów",
"warning": "Zakończono z ostrzeżeniem: przekroczono limit czasu lub częściowe wyniki",
"failed": "Nieudane",
"ranCount": "uruchomiono {count}x",
"lineCount": "{count} wiersz | {count} wierszy",
"relativeWindow": "Ostatnie {period}",
"rerunNow": "Uruchom ponownie teraz",
"rerunOriginal": "W oryginalnym czasie",
"loadOnly": "Wczytaj bez uruchamiania",
"copyLink": "Kopiuj link",
"copyQuery": "Kopiuj zapytanie",
"star": "Oznacz gwiazdką",
"unstar": "Usuń gwiazdkę",
"linkCopied": "Skopiowano link",
"queryCopied": "Skopiowano zapytanie",
"copyFunction": "Kopiuj funkcję",
"functionCopied": "Skopiowano funkcję",
"functionLabel": "Funkcja (VRL)",
"searchPlaceholder": "Szukaj zapytań i strumieni",
"allStreams": "Wszystkie strumienie",
"statusAll": "Dowolny status",
"statusOk": "Powodzenie",
"statusError": "Nieudane",
"starredOnly": "Tylko oznaczone gwiazdką",
"datePlaceholder": "dd/mm/yyyy",
"filterByDateRun": "Filtruj według daty uruchomienia",
"today": "Dzisiaj",
"yesterday": "Wczoraj",
"noMatch": "Żadne wyszukiwania nie pasują do tych filtrów",
"reset": "Zresetuj wszystko",
"resetFilters": "Zresetuj filtry",
"loadMore": "Wczytaj więcej",
"clear": "Wyczyść moją historię",
"confirmClear": "Wyczyść historię",
"clearBodyPage": "Spowoduje to usunięcie {count} ostatniego wyszukiwania na stronie {page} w organizacji {org}. Zapisane widoki nie zostaną zmienione. | Spowoduje to usunięcie {count} ostatnich wyszukiwań na stronie {page} w organizacji {org}. Zapisane widoki nie zostaną zmienione.",
"clearBodyAll": "Spowoduje to usunięcie {count} ostatniego wyszukiwania w organizacji {org} w modułach Logi, Trace i Metryki. Zapisane widoki nie zostaną zmienione. | Spowoduje to usunięcie {count} ostatnich wyszukiwań w organizacji {org} w modułach Logi, Trace i Metryki. Zapisane widoki nie zostaną zmienione.",
"includeStarred": "Usuń także moje {count} wyszukiwanie oznaczone gwiazdką | Usuń także moje {count} wyszukiwań oznaczonych gwiazdką",
"recentRow": "Ostatnie wyszukiwanie",
"deletedQuery": "Usunięto {query}",
"undo": "Cofnij"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "Desativar a execução automática",
"autoRefreshWithValue": "Atualização automática: {value}",
"searchHistory": "Histórico de pesquisa",
"usageCostHistory": "Histórico de utilização e custos",
"createScheduledSearch": "Criar pesquisa agendada",
"listScheduledSearch": "Listar pesquisas agendadas",
"scheduleSearchJob": "Agendar trabalho de pesquisa",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "Selecione a opção de detetar em"
},
"search_history": {
"title": "Histórico de pesquisa",
"title": "Histórico de utilização e custos",
"get_history": "Obter histórico",
"executed_at": "Executado em",
"sql_query": "Consulta SQL",
"delayMessage": "O histórico de pesquisa pode ser atrasado em"
"delayMessage": "O histórico de utilização e custos pode sofrer um atraso de"
},
"correlation": {
"unstableDimensionNote": "Dimensão instável - muda quando o pod é reiniciado. Padrão: todos os valores.",
@@ -20562,7 +20563,7 @@
"nodes": "Nós",
"modelPricing": "Preços do modelo",
"searchSchedulers": "Agendadores de pesquisa",
"searchHistory": "Histórico de pesquisa",
"searchHistory": "Histórico de utilização e custos",
"sessions": "Sessões",
"evalTemplates": "Avaliações online — Modelos",
"scorers": "Avaliações online — Avaliadores",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "Atualizar lista",
"alertTemplatesAdd": "Adicionar novo modelo",
"alertTemplatesRefresh": "Atualizar lista",
"logsSearchHistory": "Abrir histórico de pesquisa",
"logsSearchHistory": "Abrir histórico de utilização e custos",
"alertsImport": "Importar alerta",
"panelEditorRun": "Executar/aplicar consulta",
"panelEditorSave": "Guardar e continuar a editar",
@@ -28100,5 +28101,71 @@
"legend": "Estado do pipeline",
"legendToggle": "Mostrar ou ocultar este estado no mapa"
}
},
"recentSearches": {
"title": "Pesquisas recentes",
"aboutKeptDays": "Apenas as suas pesquisas em {org}, visíveis apenas para si. Mantidas durante {days} dia; as pesquisas com estrela permanecem até que as remova. | Apenas as suas pesquisas em {org}, visíveis apenas para si. Mantidas durante {days} dias; as pesquisas com estrela permanecem até que as remova.",
"aboutKeptForever": "Apenas as suas pesquisas em {org}, visíveis apenas para si. Mantidas até serem eliminadas.",
"pageTitle": {
"logs": "Pesquisas recentes de logs",
"traces": "Pesquisas recentes de traces",
"metrics": "Pesquisas recentes de métricas",
"dbm": "Pesquisas recentes",
"profiles": "Pesquisas recentes"
},
"page": {
"logs": "Logs",
"traces": "Traces",
"metrics": "Métricas",
"dbm": "Base de dados",
"profiles": "Perfis"
},
"allPages": "Todas as páginas",
"viewAll": "Ver tudo",
"failedCount": "{count} com falha",
"keyHints": "↑↓ mover · PgUp/PgDn deslocar consulta · Enter executar · Shift+Enter apenas carregar · Esc fechar",
"loadToFix": "Carregar para corrigir",
"emptyTitle": "Ainda não há pesquisas recentes",
"loadError": "Não foi possível carregar as pesquisas recentes",
"retry": "Tentar novamente",
"warning": "Terminou com um aviso: tempo limite excedido ou resultados parciais",
"failed": "Falhou",
"ranCount": "executada {count}x",
"lineCount": "{count} linha | {count} linhas",
"relativeWindow": "Últimos {period}",
"rerunNow": "Executar novamente agora",
"rerunOriginal": "Na hora original",
"loadOnly": "Carregar sem executar",
"copyLink": "Copiar link",
"copyQuery": "Copiar consulta",
"star": "Adicionar estrela",
"unstar": "Remover estrela",
"linkCopied": "Link copiado",
"queryCopied": "Consulta copiada",
"copyFunction": "Copiar função",
"functionCopied": "Função copiada",
"functionLabel": "Função (VRL)",
"searchPlaceholder": "Pesquisar consultas e streams",
"allStreams": "Todos os streams",
"statusAll": "Qualquer estado",
"statusOk": "Concluída",
"statusError": "Falhada",
"starredOnly": "Apenas com estrela",
"datePlaceholder": "dd/mm/aaaa",
"filterByDateRun": "Filtrar por data de execução",
"today": "Hoje",
"yesterday": "Ontem",
"noMatch": "Nenhuma pesquisa corresponde a estes filtros",
"reset": "Repor tudo",
"resetFilters": "Repor filtros",
"loadMore": "Carregar mais",
"clear": "Limpar o meu histórico",
"confirmClear": "Limpar histórico",
"clearBodyPage": "Isto remove a sua pesquisa recente ({count}) de {page} em {org}. As vistas guardadas não são afetadas. | Isto remove as suas {count} pesquisas recentes de {page} em {org}. As vistas guardadas não são afetadas.",
"clearBodyAll": "Isto remove a sua pesquisa recente ({count}) em {org}, em Logs, Traces e Métricas. As vistas guardadas não são afetadas. | Isto remove as suas {count} pesquisas recentes em {org}, em Logs, Traces e Métricas. As vistas guardadas não são afetadas.",
"includeStarred": "Remover também a minha pesquisa com estrela ({count}) | Remover também as minhas {count} pesquisas com estrela",
"recentRow": "Pesquisa recente",
"deletedQuery": "Eliminada {query}",
"undo": "Desfazer"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "Отключить автоматический запуск",
"autoRefreshWithValue": "Автообновление: {value}",
"searchHistory": "История поиска",
"usageCostHistory": "История использования и затрат",
"createScheduledSearch": "Создать запланированный поиск",
"listScheduledSearch": "Список запланированных поисков",
"scheduleSearchJob": "Запланировать задачу поиска",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "Пожалуйста, выберите опцию 'Обнаружить на'"
},
"search_history": {
"title": "История поиска",
"title": "История использования и затрат",
"get_history": "Получить историю",
"executed_at": "Выполнено в",
"sql_query": "SQL-запрос",
"delayMessage": "История поиска может быть с задержкой на"
"delayMessage": "История использования и затрат может задерживаться на"
},
"correlation": {
"unstableDimensionNote": "Нестабильное измерение - изменения при перезапуске пода. По умолчанию: все значения.",
@@ -20562,7 +20563,7 @@
"nodes": "Узлы",
"modelPricing": "Ценообразование моделей",
"searchSchedulers": "Планировщики поиска",
"searchHistory": "История поиска",
"searchHistory": "История использования и затрат",
"sessions": "Сессии",
"evalTemplates": "Онлайн-оценки — Шаблоны",
"scorers": "Онлайн-оценки — Оценщики",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "Обновить список",
"alertTemplatesAdd": "Добавить новый шаблон",
"alertTemplatesRefresh": "Обновить список",
"logsSearchHistory": "Открыть историю поиска",
"logsSearchHistory": "Открыть историю использования и затрат",
"alertsImport": "Импортировать оповещение",
"panelEditorRun": "Выполнить / применить запрос",
"panelEditorSave": "Сохранить и продолжить редактирование",
@@ -28100,5 +28101,71 @@
"legend": "Статус конвейера",
"legendToggle": "Показать или скрыть этот статус на карте"
}
},
"recentSearches": {
"title": "Недавние запросы",
"aboutKeptDays": "Только ваши запросы в {org}, видны только вам. Хранятся {days} дн.; избранные запросы остаются, пока вы их не удалите. | Только ваши запросы в {org}, видны только вам. Хранятся {days} дн.; избранные запросы остаются, пока вы их не удалите.",
"aboutKeptForever": "Только ваши запросы в {org}, видны только вам. Хранятся до удаления.",
"pageTitle": {
"logs": "Недавние запросы по логам",
"traces": "Недавние запросы по трейсам",
"metrics": "Недавние запросы по метрикам",
"dbm": "Недавние запросы",
"profiles": "Недавние запросы"
},
"page": {
"logs": "Логи",
"traces": "Трейсы",
"metrics": "Метрики",
"dbm": "База данных",
"profiles": "Профили"
},
"allPages": "Все страницы",
"viewAll": "Просмотреть все",
"failedCount": "{count} с ошибкой",
"keyHints": "↑↓ — перемещение · PgUp/PgDn — прокрутка запроса · Enter — выполнить · Shift+Enter — только загрузить · Esc — закрыть",
"loadToFix": "Загрузить для исправления",
"emptyTitle": "Недавних запросов пока нет",
"loadError": "Не удалось загрузить недавние запросы",
"retry": "Повторить",
"warning": "Завершено с предупреждением: тайм-аут или частичные результаты",
"failed": "Ошибка",
"ranCount": "запущено {count} раз",
"lineCount": "{count} строка | {count} строк",
"relativeWindow": "Последние {period}",
"rerunNow": "Запустить снова сейчас",
"rerunOriginal": "На исходное время",
"loadOnly": "Загрузить без запуска",
"copyLink": "Копировать ссылку",
"copyQuery": "Копировать запрос",
"star": "В избранное",
"unstar": "Убрать из избранного",
"linkCopied": "Ссылка скопирована",
"queryCopied": "Запрос скопирован",
"copyFunction": "Копировать функцию",
"functionCopied": "Функция скопирована",
"functionLabel": "Функция (VRL)",
"searchPlaceholder": "Поиск по запросам и потокам",
"allStreams": "Все потоки",
"statusAll": "Любой статус",
"statusOk": "Успешно",
"statusError": "Ошибка",
"starredOnly": "Только избранные",
"datePlaceholder": "дд/мм/гггг",
"filterByDateRun": "Фильтр по дате запуска",
"today": "Сегодня",
"yesterday": "Вчера",
"noMatch": "Нет поисков, соответствующих этим фильтрам",
"reset": "Сбросить всё",
"resetFilters": "Сбросить фильтры",
"loadMore": "Загрузить ещё",
"clear": "Очистить мою историю",
"confirmClear": "Очистить историю",
"clearBodyPage": "Это удалит ваш {count} недавний поиск в разделе {page} в {org}. Сохранённые представления не затронуты. | Это удалит ваши {count} недавних поисков в разделе {page} в {org}. Сохранённые представления не затронуты.",
"clearBodyAll": "Это удалит ваш {count} недавний поиск в {org} по логам, трейсам и метрикам. Сохранённые представления не затронуты. | Это удалит ваши {count} недавних поисков в {org} по логам, трейсам и метрикам. Сохранённые представления не затронуты.",
"includeStarred": "Также удалить мой {count} избранный поиск | Также удалить мои {count} избранных поисков",
"recentRow": "Недавний поиск",
"deletedQuery": "Удалено: {query}",
"undo": "Отменить"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "Canlı modu kapat",
"autoRefreshWithValue": "Otomatik Yenileme: {value}",
"searchHistory": "Arama Geçmişi",
"usageCostHistory": "Kullanım ve maliyet geçmişi",
"createScheduledSearch": "Zamanlanmış Arama Oluştur",
"listScheduledSearch": "Planlanmış Aramayı Listele",
"scheduleSearchJob": "Arama İşi Planla",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "Lütfen tespit zamanı seçeneğini seçin"
},
"search_history": {
"title": "Arama Geçmişi",
"title": "Kullanım ve maliyet geçmişi",
"get_history": "Geçmişi Getir",
"executed_at": "İdam Edildi",
"sql_query": "SQL Sorgu",
"delayMessage": "Arama Geçmişi şu şekilde gecikebilir"
"delayMessage": "Kullanım ve maliyet geçmişi gecikebilir:"
},
"correlation": {
"unstableDimensionNote": "Kararsız boyut - pod yeniden başlatıldığında değişir. Varsayılan: Tüm değerler.",
@@ -20562,7 +20563,7 @@
"nodes": "düğümler",
"modelPricing": "Model Fiyatlandırması",
"searchSchedulers": "Arama Zamanlayıcıları",
"searchHistory": "Arama Geçmişi",
"searchHistory": "Kullanım ve maliyet geçmişi",
"sessions": "Oturumlar",
"evalTemplates": "Çevrimiçi Değerlendirmeler - Şablonlar",
"scorers": "Çevrimiçi Değerlendirmeler — Puanlayıcılar",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "Listeyi yenile",
"alertTemplatesAdd": "Yeni şablon ekle",
"alertTemplatesRefresh": "Listeyi yenile",
"logsSearchHistory": "Arama geçmişini aç",
"logsSearchHistory": "Kullanım ve maliyet geçmişini aç",
"alertsImport": "Uyarıyı içe aktar",
"panelEditorRun": "Sorguyu çalıştır/uygula",
"panelEditorSave": "Kaydet ve düzenlemeye devam et",
@@ -28100,5 +28101,71 @@
"legend": "İşlem hattı durumu",
"legendToggle": "Bu durumu haritada göster veya gizle"
}
},
"recentSearches": {
"title": "Son aramalar",
"aboutKeptDays": "Yalnızca {org} içindeki aramalarınız, yalnızca size görünür. {days} gün saklanır; yıldızlı aramalar siz kaldırana kadar kalır. | Yalnızca {org} içindeki aramalarınız, yalnızca size görünür. {days} gün saklanır; yıldızlı aramalar siz kaldırana kadar kalır.",
"aboutKeptForever": "Yalnızca {org} içindeki aramalarınız, yalnızca size görünür. Silinene kadar saklanır.",
"pageTitle": {
"logs": "Son günlük aramaları",
"traces": "Son iz aramaları",
"metrics": "Son metrik aramaları",
"dbm": "Son aramalar",
"profiles": "Son aramalar"
},
"page": {
"logs": "Günlükler",
"traces": "İzler",
"metrics": "Metrikler",
"dbm": "Veritabanı",
"profiles": "Profiller"
},
"allPages": "Tüm sayfalar",
"viewAll": "Tümünü görüntüle",
"failedCount": "{count} başarısız",
"keyHints": "↑↓ gezin · PgUp/PgDn sorguda kaydır · Enter çalıştır · Shift+Enter yalnızca yükle · Esc kapat",
"loadToFix": "Düzeltmek için yükle",
"emptyTitle": "Henüz son arama yok",
"loadError": "Son aramalar yüklenemedi",
"retry": "Yeniden dene",
"warning": "Uyarıyla tamamlandı: zaman aşımı veya kısmi sonuçlar",
"failed": "Başarısız",
"ranCount": "{count} kez çalıştırıldı",
"lineCount": "{count} satır | {count} satır",
"relativeWindow": "Son {period}",
"rerunNow": "Şimdi yeniden çalıştır",
"rerunOriginal": "Orijinal zamanda",
"loadOnly": "Çalıştırmadan yükle",
"copyLink": "Bağlantıyı kopyala",
"copyQuery": "Sorguyu kopyala",
"star": "Yıldızla",
"unstar": "Yıldızı kaldır",
"linkCopied": "Bağlantı kopyalandı",
"queryCopied": "Sorgu kopyalandı",
"copyFunction": "Fonksiyonu kopyala",
"functionCopied": "Fonksiyon kopyalandı",
"functionLabel": "Fonksiyon (VRL)",
"searchPlaceholder": "Sorgu ve akışlarda ara",
"allStreams": "Tüm akışlar",
"statusAll": "Tüm durumlar",
"statusOk": "Başarılı",
"statusError": "Başarısız",
"starredOnly": "Yalnızca yıldızlı",
"datePlaceholder": "dd/mm/yyyy",
"filterByDateRun": "Çalıştırma tarihine göre filtrele",
"today": "Bugün",
"yesterday": "Dün",
"noMatch": "Bu filtrelerle eşleşen arama yok",
"reset": "Tümünü sıfırla",
"resetFilters": "Filtreleri sıfırla",
"loadMore": "Daha fazla yükle",
"clear": "Geçmişimi temizle",
"confirmClear": "Geçmişi temizle",
"clearBodyPage": "Bu, {org} içindeki {count} son {page} aramasını kaldırır. Kaydedilmiş görünümler etkilenmez. | Bu, {org} içindeki {count} son {page} aramasını kaldırır. Kaydedilmiş görünümler etkilenmez.",
"clearBodyAll": "Bu, {org} içinde Günlükler, İzler ve Metrikler genelinde {count} son aramanızı kaldırır. Kaydedilmiş görünümler etkilenmez. | Bu, {org} içinde Günlükler, İzler ve Metrikler genelinde {count} son aramanızı kaldırır. Kaydedilmiş görünümler etkilenmez.",
"includeStarred": "Ayrıca {count} yıldızlı aramamı da kaldır | Ayrıca {count} yıldızlı aramamı da kaldır",
"recentRow": "Son arama",
"deletedQuery": "{query} silindi",
"undo": "Geri al"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "Tắt Tự động chạy",
"autoRefreshWithValue": "Tự động làm mới: {value}",
"searchHistory": "Lịch sử tìm kiếm",
"usageCostHistory": "Lịch sử sử dụng & chi phí",
"createScheduledSearch": "Tạo tìm kiếm theo lịch",
"listScheduledSearch": "Danh sách tìm kiếm theo lịch",
"scheduleSearchJob": "Lên lịch công việc tìm kiếm",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "Vui lòng chọn tùy chọn phát hiện tại"
},
"search_history": {
"title": "Lịch sử tìm kiếm",
"title": "Lịch sử sử dụng & chi phí",
"get_history": "Lấy lịch sử",
"executed_at": "Thực thi lúc",
"sql_query": "Truy vấn SQL",
"delayMessage": "Lịch sử tìm kiếm có thể bị trễ trong"
"delayMessage": "Lịch sử sử dụng & chi phí có thể bị trễ"
},
"correlation": {
"unstableDimensionNote": "Thứ nguyên không ổn định - thay đổi khi pod khởi động lại. Mặc định: Tất cả giá trị.",
@@ -20562,7 +20563,7 @@
"nodes": "Nút",
"modelPricing": "Định giá mô hình",
"searchSchedulers": "Bộ lập lịch tìm kiếm",
"searchHistory": "Lịch sử tìm kiếm",
"searchHistory": "Lịch sử sử dụng & chi phí",
"sessions": "Phiên",
"evalTemplates": "Đánh giá trực tuyến — Mẫu",
"scorers": "Đánh giá trực tuyến — Bộ chấm điểm",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "Làm mới danh sách",
"alertTemplatesAdd": "Thêm mẫu mới",
"alertTemplatesRefresh": "Làm mới danh sách",
"logsSearchHistory": "Mở lịch sử tìm kiếm",
"logsSearchHistory": "Mở lịch sử sử dụng & chi phí",
"alertsImport": "Nhập cảnh báo",
"panelEditorRun": "Chạy / áp dụng truy vấn",
"panelEditorSave": "Lưu và tiếp tục chỉnh sửa",
@@ -28100,5 +28101,71 @@
"legend": "Trạng thái pipeline",
"legendToggle": "Hiện hoặc ẩn trạng thái này trên bản đồ"
}
},
"recentSearches": {
"title": "Tìm kiếm gần đây",
"aboutKeptDays": "Chỉ các tìm kiếm của bạn trong {org}, chỉ bạn nhìn thấy. Lưu giữ {days} ngày; các tìm kiếm có gắn dấu sao sẽ được giữ cho đến khi bạn xóa. | Chỉ các tìm kiếm của bạn trong {org}, chỉ bạn nhìn thấy. Lưu giữ {days} ngày; các tìm kiếm có gắn dấu sao sẽ được giữ cho đến khi bạn xóa.",
"aboutKeptForever": "Chỉ các tìm kiếm của bạn trong {org}, chỉ bạn nhìn thấy. Lưu giữ cho đến khi bị xóa.",
"pageTitle": {
"logs": "Tìm kiếm nhật ký gần đây",
"traces": "Tìm kiếm trace gần đây",
"metrics": "Tìm kiếm chỉ số gần đây",
"dbm": "Tìm kiếm gần đây",
"profiles": "Tìm kiếm gần đây"
},
"page": {
"logs": "Nhật ký",
"traces": "Trace",
"metrics": "Chỉ số",
"dbm": "Cơ sở dữ liệu",
"profiles": "Profiles"
},
"allPages": "Tất cả các trang",
"viewAll": "Xem tất cả",
"failedCount": "{count} thất bại",
"keyHints": "↑↓ di chuyển · PgUp/PgDn cuộn truy vấn · Enter chạy · Shift+Enter chỉ tải · Esc đóng",
"loadToFix": "Tải để sửa",
"emptyTitle": "Chưa có tìm kiếm gần đây",
"loadError": "Không thể tải tìm kiếm gần đây",
"retry": "Thử lại",
"warning": "Kết thúc với cảnh báo: hết thời gian chờ hoặc kết quả không đầy đủ",
"failed": "Thất bại",
"ranCount": "đã chạy {count} lần",
"lineCount": "{count} dòng | {count} dòng",
"relativeWindow": "{period} gần nhất",
"rerunNow": "Chạy lại ngay",
"rerunOriginal": "Theo thời điểm gốc",
"loadOnly": "Tải mà không chạy",
"copyLink": "Sao chép liên kết",
"copyQuery": "Sao chép truy vấn",
"star": "Gắn dấu sao",
"unstar": "Bỏ dấu sao",
"linkCopied": "Đã sao chép liên kết",
"queryCopied": "Đã sao chép truy vấn",
"copyFunction": "Sao chép hàm",
"functionCopied": "Đã sao chép hàm",
"functionLabel": "Hàm (VRL)",
"searchPlaceholder": "Tìm kiếm truy vấn và stream",
"allStreams": "Tất cả stream",
"statusAll": "Mọi trạng thái",
"statusOk": "Thành công",
"statusError": "Thất bại",
"starredOnly": "Chỉ có dấu sao",
"datePlaceholder": "dd/mm/yyyy",
"filterByDateRun": "Lọc theo ngày thực hiện",
"today": "Hôm nay",
"yesterday": "Hôm qua",
"noMatch": "Không có tìm kiếm nào khớp với các bộ lọc này",
"reset": "Đặt lại tất cả",
"resetFilters": "Đặt lại bộ lọc",
"loadMore": "Tải thêm",
"clear": "Xóa lịch sử của tôi",
"confirmClear": "Xóa lịch sử",
"clearBodyPage": "Thao tác này sẽ xóa {count} tìm kiếm {page} gần đây của bạn trong {org}. Các chế độ xem đã lưu không bị ảnh hưởng. | Thao tác này sẽ xóa {count} tìm kiếm {page} gần đây của bạn trong {org}. Các chế độ xem đã lưu không bị ảnh hưởng.",
"clearBodyAll": "Thao tác này sẽ xóa {count} tìm kiếm gần đây của bạn trong {org} trên Nhật ký, Trace và Chỉ số. Các chế độ xem đã lưu không bị ảnh hưởng. | Thao tác này sẽ xóa {count} tìm kiếm gần đây của bạn trong {org} trên Nhật ký, Trace và Chỉ số. Các chế độ xem đã lưu không bị ảnh hưởng.",
"includeStarred": "Cũng xóa {count} tìm kiếm đã gắn sao của tôi | Cũng xóa {count} tìm kiếm đã gắn sao của tôi",
"recentRow": "Tìm kiếm gần đây",
"deletedQuery": "Đã xóa {query}",
"undo": "Hoàn tác"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "关闭数据流模式",
"autoRefreshWithValue": "自动刷新:{value}",
"searchHistory": "搜索历史",
"usageCostHistory": "用量与成本历史",
"createScheduledSearch": "创建预设搜索",
"listScheduledSearch": "列出预设搜索",
"scheduleSearchJob": "安排搜索作业",
@@ -15360,11 +15361,11 @@
"select_detect_at_option": "请选择检测位置选项"
},
"search_history": {
"title": "搜索历史",
"title": "用量与成本历史",
"get_history": "获取历史记录",
"executed_at": "执行于",
"sql_query": "SQL 查询",
"delayMessage": "搜索历史记录可能会延迟"
"delayMessage": "用量与成本历史可能会延迟"
},
"correlation": {
"unstableDimensionNote": "不稳定的维度 - 在 Pod 重启时变化。默认:所有值。",
@@ -20562,7 +20563,7 @@
"nodes": "节点",
"modelPricing": "模型定价",
"searchSchedulers": "搜索调度器",
"searchHistory": "搜索历史",
"searchHistory": "用量与成本历史",
"sessions": "会话",
"evalTemplates": "在线评估—模板",
"scorers": "在线评估 — 评分器",
@@ -20650,7 +20651,7 @@
"alertSourcesRefresh": "刷新列表",
"alertTemplatesAdd": "添加新模板",
"alertTemplatesRefresh": "刷新清单",
"logsSearchHistory": "打开搜索记录",
"logsSearchHistory": "打开用量与成本历史",
"alertsImport": "导入告警",
"panelEditorRun": "运行/应用查询",
"panelEditorSave": "保存并继续编辑",
@@ -28100,5 +28101,71 @@
"legend": "流水线状态",
"legendToggle": "在地图上显示或隐藏此状态"
}
},
"recentSearches": {
"title": "最近搜索",
"aboutKeptDays": "仅显示您在 {org} 中的搜索,且仅您可见。保留 {days} 天;星标搜索会一直保留,直到您将其移除。 | 仅显示您在 {org} 中的搜索,且仅您可见。保留 {days} 天;星标搜索会一直保留,直到您将其移除。",
"aboutKeptForever": "仅显示您在 {org} 中的搜索,且仅您可见。保留至删除。",
"pageTitle": {
"logs": "最近的日志搜索",
"traces": "最近的追踪搜索",
"metrics": "最近的指标搜索",
"dbm": "最近搜索",
"profiles": "最近搜索"
},
"page": {
"logs": "日志",
"traces": "追踪",
"metrics": "指标",
"dbm": "数据库",
"profiles": "性能分析"
},
"allPages": "所有页面",
"viewAll": "查看全部",
"failedCount": "失败 {count} 个",
"keyHints": "↑↓ 移动 · PgUp/PgDn 滚动查询 · Enter 运行 · Shift+Enter 仅加载 · Esc 关闭",
"loadToFix": "加载以修复",
"emptyTitle": "暂无最近搜索",
"loadError": "无法加载最近搜索",
"retry": "重试",
"warning": "已完成但出现警告:超时或结果不完整",
"failed": "失败",
"ranCount": "已运行 {count} 次",
"lineCount": "{count} 行 | {count} 行",
"relativeWindow": "最近 {period}",
"rerunNow": "立即重新运行",
"rerunOriginal": "按原始时间",
"loadOnly": "仅加载不运行",
"copyLink": "复制链接",
"copyQuery": "复制查询",
"star": "星标",
"unstar": "取消星标",
"linkCopied": "链接已复制",
"queryCopied": "查询已复制",
"copyFunction": "复制函数",
"functionCopied": "函数已复制",
"functionLabel": "函数 (VRL)",
"searchPlaceholder": "搜索查询和数据流",
"allStreams": "所有数据流",
"statusAll": "任意状态",
"statusOk": "成功",
"statusError": "失败",
"starredOnly": "仅星标",
"datePlaceholder": "dd/mm/yyyy",
"filterByDateRun": "按运行日期过滤",
"today": "今天",
"yesterday": "昨天",
"noMatch": "没有搜索匹配这些过滤器",
"reset": "全部重置",
"resetFilters": "重置过滤器",
"loadMore": "加载更多",
"clear": "清除我的历史记录",
"confirmClear": "清除历史记录",
"clearBodyPage": "这将删除您在 {org} 中的 {count} 条最近 {page} 搜索。已保存的视图不受影响。 | 这将删除您在 {org} 中的 {count} 条最近 {page} 搜索。已保存的视图不受影响。",
"clearBodyAll": "这将删除您在 {org} 中跨日志、追踪和指标的 {count} 条最近搜索。已保存的视图不受影响。 | 这将删除您在 {org} 中跨日志、追踪和指标的 {count} 条最近搜索。已保存的视图不受影响。",
"includeStarred": "同时删除我的 {count} 条已收藏搜索 | 同时删除我的 {count} 条已收藏搜索",
"recentRow": "最近搜索",
"deletedQuery": "已删除 {query}",
"undo": "撤销"
}
}
+71 -4
View File
@@ -1180,6 +1180,7 @@
"turnOffLiveMode": "關閉自動執行",
"autoRefreshWithValue": "自動重新整理:{value}",
"searchHistory": "搜尋歷史紀錄",
"usageCostHistory": "使用量與成本歷程",
"createScheduledSearch": "建立排程搜尋",
"listScheduledSearch": "排程搜尋列表",
"scheduleSearchJob": "排程搜尋工作",
@@ -15355,11 +15356,11 @@
"select_detect_at_option": "請選擇偵測於選項"
},
"search_history": {
"title": "搜尋歷史紀錄",
"title": "使用量與成本歷程",
"get_history": "取得歷史紀錄",
"executed_at": "執行於",
"sql_query": "SQL 查詢",
"delayMessage": "搜尋歷史紀錄可能延遲"
"delayMessage": "使用量與成本歷程可能延遲"
},
"correlation": {
"unstableDimensionNote": "不穩定的維度 - 在 Pod 重新啟動時會變更。預設值:所有值。",
@@ -20557,7 +20558,7 @@
"nodes": "節點",
"modelPricing": "模型定價",
"searchSchedulers": "搜尋排程器",
"searchHistory": "搜尋歷史記錄",
"searchHistory": "使用量與成本歷程",
"sessions": "工作階段",
"evalTemplates": "線上評估 — 範本",
"scorers": "線上評估 — 評分器",
@@ -20645,7 +20646,7 @@
"alertSourcesRefresh": "重新整理清單",
"alertTemplatesAdd": "新增範本",
"alertTemplatesRefresh": "重新整理清單",
"logsSearchHistory": "開啟搜尋歷史",
"logsSearchHistory": "開啟使用量與成本歷程",
"alertsImport": "匯入警示",
"panelEditorRun": "執行/套用查詢",
"panelEditorSave": "儲存並繼續編輯",
@@ -28095,5 +28096,71 @@
"legend": "管線狀態",
"legendToggle": "在地圖上顯示或隱藏此狀態"
}
},
"recentSearches": {
"title": "最近搜尋",
"aboutKeptDays": "只有您在 {org} 中的搜尋,僅您可見。保留 {days} 天;已加星號的搜尋會保留直到您移除。 | 只有您在 {org} 中的搜尋,僅您可見。保留 {days} 天;已加星號的搜尋會保留直到您移除。",
"aboutKeptForever": "只有您在 {org} 中的搜尋,僅您可見。保留至刪除為止。",
"pageTitle": {
"logs": "最近的日誌搜尋",
"traces": "最近的追蹤搜尋",
"metrics": "最近的指標搜尋",
"dbm": "最近搜尋",
"profiles": "最近搜尋"
},
"page": {
"logs": "日誌",
"traces": "追蹤",
"metrics": "指標",
"dbm": "資料庫",
"profiles": "效能剖析"
},
"allPages": "所有頁面",
"viewAll": "檢視全部",
"failedCount": "{count} 個失敗",
"keyHints": "↑↓ 移動 · PgUp/PgDn 捲動查詢 · Enter 執行 · Shift+Enter 僅載入 · Esc 關閉",
"loadToFix": "載入以修正",
"emptyTitle": "尚無最近搜尋",
"loadError": "無法載入最近搜尋",
"retry": "重試",
"warning": "已完成但有警告:逾時或部分結果",
"failed": "失敗",
"ranCount": "已執行 {count} 次",
"lineCount": "{count} 行 | {count} 行",
"relativeWindow": "最近 {period}",
"rerunNow": "立即重新執行",
"rerunOriginal": "依原始時間",
"loadOnly": "僅載入不執行",
"copyLink": "複製連結",
"copyQuery": "複製查詢",
"star": "加星號",
"unstar": "取消星號",
"linkCopied": "已複製連結",
"queryCopied": "已複製查詢",
"copyFunction": "複製函式",
"functionCopied": "已複製函式",
"functionLabel": "函式 (VRL)",
"searchPlaceholder": "搜尋查詢與串流",
"allStreams": "所有串流",
"statusAll": "任何狀態",
"statusOk": "成功",
"statusError": "失敗",
"starredOnly": "僅星號",
"datePlaceholder": "dd/mm/yyyy",
"filterByDateRun": "依執行日期篩選",
"today": "今天",
"yesterday": "昨天",
"noMatch": "沒有搜尋符合這些篩選條件",
"reset": "全部重設",
"resetFilters": "重設篩選條件",
"loadMore": "載入更多",
"clear": "清除我的歷史記錄",
"confirmClear": "清除歷史記錄",
"clearBodyPage": "這會移除您在 {org} 中最近 {count} 筆 {page} 搜尋。已儲存的檢視不會受到影響。 | 這會移除您在 {org} 中最近 {count} 筆 {page} 搜尋。已儲存的檢視不會受到影響。",
"clearBodyAll": "這會移除您在 {org} 中最近 {count} 筆搜尋,涵蓋日誌、追蹤與指標。已儲存的檢視不會受到影響。 | 這會移除您在 {org} 中最近 {count} 筆搜尋,涵蓋日誌、追蹤與指標。已儲存的檢視不會受到影響。",
"includeStarred": "同時移除我的 {count} 筆已加星號搜尋 | 同時移除我的 {count} 筆已加星號搜尋",
"recentRow": "最近搜尋",
"deletedQuery": "已刪除 {query}",
"undo": "復原"
}
}
@@ -0,0 +1,271 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount } from "@vue/test-utils";
import Index from "./Index.vue";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
import { readLogsSignature, resetLogsAutoRunForTests } from "@/composables/useLogs/logsAutoRun";
import { buildLogsRecord } from "@/utils/queryHistory/logs";
import type { QueryHistoryEntry } from "@/services/query_history";
import { openRecentOnItsPage, recentSearchRoute } from "@/utils/queryHistory/routes";
import { landingRouter } from "@/test/unit/helpers/recentLanding";
interface SentSearch {
type: string;
queryReq: { query: { sql: string } };
}
const { sent, streams } = vi.hoisted(() => ({
sent: [] as SentSearch[],
streams: [
{
name: "app",
schema: [
{ name: "_timestamp", type: "Int64" },
{ name: "method", type: "Utf8" },
{ name: "status", type: "Int64" },
],
settings: {},
},
],
}));
vi.mock("@/stores", async () => ({ default: (await import("@/test/unit/helpers/store")).default }));
vi.mock("@/composables/useStreamingSearch", () => ({
default: () => ({
fetchQueryDataWithHttpStream: (payload: SentSearch) => {
sent.push(payload);
return Promise.resolve();
},
cancelStreamQueryBasedOnRequestId: vi.fn(),
}),
}));
vi.mock("@/composables/useStreams", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/composables/useStreams")>();
return {
...actual,
default: () => ({
...actual.default(),
getStream: async (name: string) => streams.find((stream) => stream.name === name),
getStreams: async () => ({ list: streams }),
}),
};
});
const app = document.createElement("div");
app.id = "app";
document.body.appendChild(app);
const recorded = (query: string): QueryHistoryEntry => {
const signature = readLogsSignature(wrapper.vm.searchObj);
const body = buildLogsRecord({
signature: { ...signature, query, sqlMode: true, streams: ["app"] },
startUs: 1,
endUs: 2,
columns: [],
result: { status: "ok" },
});
if (!body) throw new Error("no record for " + query);
return {
...body,
id: query,
query_key: "k",
starred: false,
run_count: 1,
created_at: 1,
updated_at: 2,
};
};
const Q1 = "SELECT * FROM \"app\" WHERE method = 'GET'";
const Q2 = 'SELECT * FROM "app" WHERE status >= 500';
let wrapper: ReturnType<typeof mount<typeof Index>>;
const WAIT = { timeout: 5000 };
interface SearchBarApi {
restoreRecent: (entry: QueryHistoryEntry, when: "now" | "original", run: boolean) => void;
}
const restore = async (query: string, run: boolean) => {
const searchBar = (wrapper.vm as unknown as { searchBarRef: SearchBarApi }).searchBarRef;
searchBar.restoreRecent(recorded(query), "now", run);
await flushPromises();
};
const searchesFor = (text: string) =>
sent.filter((s) => s.type === "search" && s.queryReq.query.sql.includes(text)).length;
describe("Logs re-apply from recent searches", { timeout: 30000 }, () => {
beforeEach(async () => {
resetLogsAutoRunForTests();
sent.length = 0;
(store.state as Record<string, unknown>).logs = { isInitialized: true, logs: {} };
vi.spyOn(store, "dispatch").mockResolvedValue(undefined as never);
wrapper = mount(Index, {
attachTo: "#app",
global: { provide: { store }, plugins: [i18n, router] },
});
await flushPromises();
});
afterEach(() => {
wrapper?.unmount();
vi.restoreAllMocks();
resetLogsAutoRunForTests();
});
it("applies two re-runs of different rows in a row", async () => {
const state = wrapper.vm.searchObj;
await restore(Q1, true);
await vi.waitFor(() => expect(state.data.query).toBe(Q1), WAIT);
await restore(Q2, true);
await vi.waitFor(() => expect(state.data.query).toBe(Q2), WAIT);
expect(state.data.editorValue).toBe(Q2);
await vi.waitFor(() => expect(searchesFor("status >= 500")).toBe(1), WAIT);
});
it("runs the same row twice when it is re-run twice", async () => {
await restore(Q2, true);
await vi.waitFor(() => expect(searchesFor("status >= 500")).toBe(1), WAIT);
await restore(Q2, true);
await vi.waitFor(() => expect(searchesFor("status >= 500")).toBe(2), WAIT);
});
it("loads without running after a re-run, so no run request leaks into the next restore", async () => {
const state = wrapper.vm.searchObj;
await restore(Q2, true);
await vi.waitFor(() => expect(searchesFor("status >= 500")).toBe(1), WAIT);
await restore(Q1, false);
await vi.waitFor(() => expect(state.data.query).toBe(Q1), WAIT);
await flushPromises();
expect(searchesFor("method = 'GET'")).toBe(0);
});
it("runs only the last of two overlapping restores when that one asked to run", async () => {
const state = wrapper.vm.searchObj;
const searchBar = (wrapper.vm as unknown as { searchBarRef: SearchBarApi }).searchBarRef;
searchBar.restoreRecent(recorded(Q1), "now", false);
searchBar.restoreRecent(recorded(Q2), "now", true);
await vi.waitFor(() => expect(searchesFor("status >= 500")).toBe(1), WAIT);
await flushPromises();
expect(state.data.query).toBe(Q2);
expect(searchesFor("method = 'GET'")).toBe(0);
expect(searchesFor("status >= 500")).toBe(1);
});
it("re-applies an AI-chat landing once, not again when a column or the refresh interval changes the URL", async () => {
const state = wrapper.vm.searchObj;
const org = store.state.selectedOrganization.identifier;
const landing = recentSearchRoute(recorded(Q1), "now", org, "load")!;
const query = { ...landing.query, type: "ai_chat_query" };
router.currentRoute.value = router.resolve({ ...landing, query });
await vi.waitFor(() => expect(state.data.query).toBe(Q1), WAIT);
await new Promise((resolve) => setTimeout(resolve, 300));
await flushPromises();
state.data.editorValue = Q2;
state.data.query = Q2;
router.currentRoute.value = router.resolve({
...landing,
query: { ...query, fields: "status" },
});
await flushPromises();
router.currentRoute.value = router.resolve({
...landing,
query: { ...query, fields: "status", refresh: "10" },
});
await new Promise((resolve) => setTimeout(resolve, 300));
await flushPromises();
expect(state.data.editorValue).toBe(Q2);
expect(state.data.query).toBe(Q2);
});
it("does not run a re-run that a load-only restore overtook", async () => {
const state = wrapper.vm.searchObj;
const searchBar = (wrapper.vm as unknown as { searchBarRef: SearchBarApi }).searchBarRef;
searchBar.restoreRecent(recorded(Q2), "now", true);
searchBar.restoreRecent(recorded(Q1), "now", false);
await vi.waitFor(() => expect(state.data.query).toBe(Q1), WAIT);
await new Promise((resolve) => setTimeout(resolve, 300));
await flushPromises();
expect(searchesFor("status >= 500")).toBe(0);
expect(searchesFor("method = 'GET'")).toBe(0);
});
});
describe("Logs landing for a restore from another page", { timeout: 30000 }, () => {
beforeEach(async () => {
// The server default: the picker's first emit on mount does not search.
store.state.zoConfig.query_on_stream_selection = true;
resetLogsAutoRunForTests();
sent.length = 0;
(store.state as Record<string, unknown>).logs = { isInitialized: true, logs: {} };
vi.spyOn(store, "dispatch").mockImplementation((async (type: string, payload: unknown) => {
if (type === "logs/setIsInitialized") {
(store.state as { logs: { isInitialized: unknown } }).logs.isInitialized = payload;
}
}) as never);
wrapper = mount(Index, {
attachTo: "#app",
global: { provide: { store }, plugins: [i18n, router] },
});
await flushPromises();
});
afterEach(() => {
wrapper?.unmount();
vi.restoreAllMocks();
resetLogsAutoRunForTests();
store.state.zoConfig.query_on_stream_selection = false;
});
// The page left with Q1 cached for the same stream and org, then another page restores Q2 here.
const landWith = async (run: boolean) => {
await restore(Q1, false);
await vi.waitFor(() => expect(wrapper.vm.searchObj.data.query).toBe(Q1), WAIT);
const entry = recorded(Q2);
wrapper.unmount();
sent.length = 0;
const org = store.state.selectedOrganization.identifier;
await openRecentOnItsPage(landingRouter().router, entry, "now", org, run);
// A full navigation never settles in this harness; the landing reads only the current route.
router.currentRoute.value = router.resolve(
recentSearchRoute(entry, "now", org, run ? "run" : "load")!,
);
wrapper = mount(Index, {
attachTo: "#app",
global: { provide: { store }, plugins: [i18n, router] },
});
await flushPromises();
};
it("applies the picked query over the cached one, and Load without running does not run it", async () => {
await landWith(false);
await vi.waitFor(() => expect(wrapper.vm.searchObj.data.query).toBe(Q2), WAIT);
await new Promise((resolve) => setTimeout(resolve, 300));
await flushPromises();
expect(searchesFor("status >= 500")).toBe(0);
});
it("runs the picked query once when it was restored with Run", async () => {
await landWith(true);
await vi.waitFor(() => expect(wrapper.vm.searchObj.data.query).toBe(Q2), WAIT);
await vi.waitFor(() => expect(searchesFor("status >= 500")).toBe(1), WAIT);
});
});
+22 -10
View File
@@ -328,7 +328,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:query-window-us="queryWindowUs"
:timezone="store.state.timezone"
@jump-to-stream-data="onJumpToStreamData"
@open-history="showSearchHistoryfn"
@open-history="openSearchHistory"
@ask-ai="onAskAiFixQuery"
/>
</div>
@@ -567,7 +567,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:query-window-us="queryWindowUs"
:timezone="store.state.timezone"
@jump-to-stream-data="onJumpToStreamData"
@open-history="showSearchHistoryfn"
@open-history="openSearchHistory"
@ask-ai="onAskAiFixQuery"
/>
<OEmptyState
@@ -703,6 +703,8 @@ import useBreakpoint from "@/composables/useBreakpoint";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import LogsNoEventsState from "@/plugins/logs/LogsNoEventsState.vue";
import { logsReapplyRequest } from "@/utils/queryHistory/logs";
import { takeRecentLanding } from "@/utils/queryHistory/routes";
import LogsNoDataState from "@/plugins/logs/LogsNoDataState.vue";
import FirstDataPanel from "@/components/ingestion/FirstDataPanel.vue";
import type { FirstEventResult } from "@/composables/firstEvent/useFirstEventWatch";
@@ -1184,10 +1186,13 @@ export default defineComponent({
}
},
);
// Two primitive sources, so other route changes never re-apply; a recent-searches restore bumps seq to restore one row twice.
watch(
() => router.currentRoute.value.query.type,
async (type) => {
if (type == "search_history_re_apply" || type == "ai_chat_query") {
[() => router.currentRoute.value.query.type, () => logsReapplyRequest.value.seq],
async ([type, seq], [, prevSeq]) => {
const requested = seq !== prevSeq;
const runRequested = requested && logsReapplyRequest.value.run;
if (requested || type === "search_history_re_apply" || type === "ai_chat_query") {
searchObj.meta.jobId = "";
resetRunStateForReapply();
@@ -1197,11 +1202,7 @@ export default defineComponent({
resetSearchObj();
// Set time range based on source type
if (
type == "ai_chat_query" &&
router.currentRoute.value.query.from &&
router.currentRoute.value.query.to
) {
if (router.currentRoute.value.query.from && router.currentRoute.value.query.to) {
searchBarRef.value.dateTimeRef.setAbsoluteTime(
router.currentRoute.value.query.from,
router.currentRoute.value.query.to,
@@ -1224,6 +1225,8 @@ export default defineComponent({
await getFunctions();
await extractFields();
refreshData();
// A newer restore started meanwhile; it carries its own run decision.
if (runRequested && logsReapplyRequest.value.seq === seq) runQueryFn();
}
},
);
@@ -1505,6 +1508,8 @@ export default defineComponent({
try {
// restoreUrlQueryParams() deletes a search_history_re_apply `type` off the route, so read it first.
const arrivalType = router.currentRoute.value.query.type;
// Claimed on arrival, so a restore that lands on another tab is not left for a later landing.
const landing = takeRecentLanding("logs");
isRouteChanged();
if (!store.state.logs.isInitialized) {
searchObj.organizationIdentifier = store.state.selectedOrganization.identifier;
@@ -1561,6 +1566,7 @@ export default defineComponent({
if (isLogsTab() || searchObj.meta.logsVisualizeToggle === "drilldown") {
if (RE_APPLY_QUERY_TYPES.includes(arrivalType)) {
await applyReAppliedQuery();
if (landing?.run) runQueryFn();
} else {
searchObj.loading = true;
loadLogsData("landing", { origin: currentInitOrigin() ?? undefined });
@@ -1964,6 +1970,11 @@ export default defineComponent({
handleRunQueryFn();
}
};
// The usage page stays the fallback when recent searches are turned off.
const openSearchHistory = () => {
if (!searchBarRef.value?.openRecentSearches?.()) showSearchHistoryfn();
};
const showSearchHistoryfn = () => {
// Search History is now its own route (was an `action=history` overlay).
// Forward the active stream type/name so the history shown there matches
@@ -4087,6 +4098,7 @@ export default defineComponent({
onChangeInterval,
onAutoIntervalTrigger,
showSearchHistoryfn,
openSearchHistory,
isAiEnabled,
onSelectStream,
onPickStream,
@@ -0,0 +1,255 @@
// 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 { afterEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h, nextTick } from "vue";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
import SearchBar from "@/plugins/logs/SearchBar.vue";
interface FocusDropdownVm {
recentFocusOpen: boolean;
onQueryEditorFocus: () => void;
queryEditorBoxRef: HTMLElement | null;
}
describe("logs recent-searches focus dropdown and the editor's Escape", () => {
let wrapper: VueWrapper | null = null;
afterEach(() => {
wrapper?.unmount();
wrapper = null;
vi.clearAllMocks();
});
it("closes the dropdown on Escape and still lets the editor handle it", async () => {
wrapper = mount(SearchBar, {
attachTo: document.body,
global: {
provide: { store },
plugins: [i18n, router],
stubs: { QueryEditor: true, RecentSearchesFocusDropdown: true },
},
});
const vm = wrapper.vm as unknown as FocusDropdownVm;
expect(vm.queryEditorBoxRef).not.toBeNull();
// Stands in for Monaco, whose Escape binding moves focus out of the editor and stops the event.
const editorEscape = vi.fn();
const editor = document.createElement("textarea");
editor.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
editorEscape(e.defaultPrevented);
e.stopPropagation();
});
const monaco = document.createElement("div");
monaco.className = "monaco-editor";
monaco.appendChild(editor);
vm.queryEditorBoxRef!.appendChild(monaco);
vm.recentFocusOpen = true;
await nextTick();
editor.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
);
expect(vm.recentFocusOpen).toBe(false);
expect(editorEscape).toHaveBeenCalledExactlyOnceWith(false);
});
it("hides the typing placeholder while the dropdown is open, so only one list changes", async () => {
wrapper = mount(SearchBar, {
attachTo: document.body,
global: {
provide: { store },
plugins: [i18n, router],
stubs: { QueryEditor: true, RecentSearchesFocusDropdown: true },
},
});
const vm = wrapper.vm as unknown as FocusDropdownVm & {
searchObj: { data: { editorValue: string }; meta: Record<string, unknown> };
};
vm.searchObj.data.editorValue = "";
vm.searchObj.meta.queryEditorPlaceholderFlag = true;
vm.searchObj.meta.nlpMode = false;
vm.recentFocusOpen = false;
await nextTick();
expect(wrapper.find(".query-editor-placeholder-overlay").exists()).toBe(true);
vm.recentFocusOpen = true;
await nextTick();
expect(wrapper.find(".query-editor-placeholder-overlay").exists()).toBe(false);
vm.recentFocusOpen = false;
await nextTick();
expect(wrapper.find(".query-editor-placeholder-overlay").exists()).toBe(true);
});
it("opens when an edit clears the focused editor and closes on the next character", async () => {
store.state.zoConfig.query_history_enabled = true;
wrapper = mount(SearchBar, {
attachTo: document.body,
global: {
provide: { store },
plugins: [i18n, router],
stubs: { QueryEditor: true, RecentSearchesFocusDropdown: true },
},
});
const vm = wrapper.vm as unknown as FocusDropdownVm & {
onEditorUserEdit: (isBlank: boolean, wasBlank: boolean) => void;
searchObj: { data: { editorValue: string; query: string } };
};
vm.searchObj.data.editorValue = "code=200";
vm.onQueryEditorFocus();
expect(vm.recentFocusOpen).toBe(false);
vm.onEditorUserEdit(true, false);
expect(vm.recentFocusOpen).toBe(true);
vm.onEditorUserEdit(false, true);
expect(vm.recentFocusOpen).toBe(false);
vm.searchObj.data.editorValue = "";
vm.onQueryEditorFocus();
expect(vm.recentFocusOpen).toBe(true);
vm.recentFocusOpen = false;
vm.onEditorUserEdit(true, false);
expect(vm.recentFocusOpen).toBe(true);
delete store.state.zoConfig.query_history_enabled;
});
it("stays closed when Escape in the list hands focus back to the editor, until the next real focus", async () => {
store.state.zoConfig.query_history_enabled = true;
const route = router.currentRoute.value;
router.currentRoute.value = router.resolve({ name: "logs" });
// Monaco fires its focus event inside focus(), in the same tick as the dismissal.
const EditorStub = defineComponent({
emits: ["focus"],
setup(_, { emit, expose }) {
expose({ focus: () => emit("focus") });
return () => h("div");
},
});
wrapper = mount(SearchBar, {
attachTo: document.body,
global: {
provide: { store },
plugins: [i18n, router],
stubs: { QueryEditor: EditorStub, RecentSearchesFocusDropdown: true },
},
});
await vi.dynamicImportSettled();
await flushPromises();
const vm = wrapper.vm as unknown as FocusDropdownVm & {
onEditorUserEdit: (isBlank: boolean, wasBlank: boolean) => void;
searchObj: { data: { editorValue: string; query: string } };
};
vm.searchObj.data.editorValue = "";
vm.searchObj.data.query = "";
vm.onQueryEditorFocus();
expect(vm.recentFocusOpen).toBe(true);
const dropdown = wrapper.findComponent({ name: "RecentSearchesFocusDropdown" });
dropdown.vm.$emit("update:open", false);
dropdown.vm.$emit("dismiss");
await nextTick();
expect(vm.recentFocusOpen).toBe(false);
vm.onQueryEditorFocus();
expect(vm.recentFocusOpen).toBe(true);
vm.recentFocusOpen = false;
dropdown.vm.$emit("dismiss");
vm.onEditorUserEdit(false, true);
vm.onEditorUserEdit(true, false);
expect(vm.recentFocusOpen).toBe(true);
delete store.state.zoConfig.query_history_enabled;
router.currentRoute.value = route;
});
describe("in the full-screen editor", () => {
type FullScreenVm = FocusDropdownVm & {
isFocused: boolean;
noteEditorPointer: () => void;
handleEscKey: (event: KeyboardEvent) => void;
searchObj: { data: { editorValue: string } };
};
const mountFullScreen = async () => {
store.state.zoConfig.query_history_enabled = true;
wrapper = mount(SearchBar, {
attachTo: document.body,
global: {
provide: { store },
plugins: [i18n, router],
stubs: { QueryEditor: true, RecentSearchesFocusDropdown: true },
},
});
const vm = wrapper.vm as unknown as FullScreenVm;
vm.searchObj.data.editorValue = "";
vm.isFocused = true;
await nextTick();
return vm;
};
afterEach(() => {
delete store.state.zoConfig.query_history_enabled;
});
it("opens for a click into the editor, not for a scripted focus", async () => {
const vm = await mountFullScreen();
vm.onQueryEditorFocus();
expect(vm.recentFocusOpen).toBe(false);
vm.noteEditorPointer();
vm.onQueryEditorFocus();
expect(vm.recentFocusOpen).toBe(true);
});
it("closes only the list on the first Esc and keeps the editor's Esc from running", async () => {
const vm = await mountFullScreen();
const editorEscape = vi.fn();
const monaco = document.createElement("div");
monaco.className = "monaco-editor";
const editor = document.createElement("textarea");
editor.addEventListener("keydown", editorEscape);
monaco.appendChild(editor);
vm.queryEditorBoxRef!.appendChild(monaco);
vm.recentFocusOpen = true;
await nextTick();
const escape = new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
});
editor.dispatchEvent(escape);
expect(vm.recentFocusOpen).toBe(false);
expect(escape.defaultPrevented).toBe(true);
expect(editorEscape).not.toHaveBeenCalled();
expect(vm.isFocused).toBe(true);
});
it("keeps full-screen while a recent-searches list or drawer takes the Esc", async () => {
const vm = await mountFullScreen();
const layer = document.createElement("div");
layer.setAttribute("data-test", "recent-searches-drawer");
document.body.appendChild(layer);
vm.handleEscKey(new KeyboardEvent("keydown", { key: "Escape" }));
expect(vm.isFocused).toBe(true);
layer.remove();
vm.handleEscKey(new KeyboardEvent("keydown", { key: "Escape" }));
expect(vm.isFocused).toBe(false);
});
});
});
+167 -6
View File
@@ -694,7 +694,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OIcon name="history" size="sm" />
</span>
</template>
{{ t("search.searchHistory") }}
{{ t("search.usageCostHistory") }}
</ODropdownItem>
</ODropdownGroup>
@@ -999,6 +999,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</ODropdown>
</OButtonGroup>
<RecentSearchesButton
source="logs"
:examples="recentExamples"
data-test="logs-search-bar-recent-searches-btn"
@run="(entry, when) => restoreRecent(entry, when, true)"
@load="(entry, when) => restoreRecent(entry, when, false)"
@view-all="openRecentSearches"
@example="applyRecentExample"
/>
<div
v-if="
searchObj.meta.logsVisualizeToggle === 'visualize' ||
@@ -1306,7 +1315,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<template #before>
<div
ref="queryEditorBoxRef"
class="relative flex h-full flex-col overflow-hidden"
@keydown.capture="onRecentEditorKeydown"
@pointerdown.capture="noteEditorPointer"
:class="{
'rounded-r-none border-e-0': searchObj.data.transformType,
'fn-editor-open': showFunctionEditor,
@@ -1320,6 +1332,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:keywords="effectiveKeywords"
:suggestions="effectiveSuggestions"
:field-value-resolver="resolveFieldValues"
:recent-queries="recentQueries"
:debounce-time="100"
:nlp-mode="searchObj.meta.nlpMode"
:has-expand-button="!showFunctionEditor"
@@ -1352,12 +1365,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@focus="onQueryEditorFocus"
@blur="handleQueryEditorBlur"
/>
<RecentSearchesFocusDropdown
ref="recentFocusRef"
v-model:open="recentFocusOpen"
source="logs"
:anchor="queryEditorBoxRef"
:examples="recentExamples"
@run="(entry, when) => restoreRecent(entry, when, true)"
@load="(entry, when) => restoreRecent(entry, when, false)"
@view-all="openRecentSearches"
@example="applyRecentExample"
@dismiss="dismissRecentFocus"
/>
<!-- Query editor placeholder overlay — shown when editor is empty and unfocused -->
<div
v-if="
searchObj.data.editorValue == '' &&
searchObj.meta.queryEditorPlaceholderFlag &&
!searchObj.meta.nlpMode
!searchObj.meta.nlpMode &&
!recentFocusOpen
"
class="query-editor-placeholder-overlay pointer-events-none absolute top-0 right-0 bottom-0 left-0 z-1 flex items-start [padding:0.1875rem_0.5rem_0_2.15rem] select-none"
>
@@ -1429,6 +1455,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
<RecentSearchesDrawer ref="recentDrawerRef" source="logs" @restore="restoreRecent" />
<ODialog
data-test="search-bar-confirm-dialog"
ref="confirmDialog"
@@ -1743,6 +1770,28 @@ import DateTime from "@/components/DateTime.vue";
import ShareButton from "@/components/common/ShareButton.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import RecentSearchesButton from "@/components/queryHistory/RecentSearchesButton.vue";
import RecentSearchesDrawer from "@/components/queryHistory/RecentSearchesDrawer.vue";
import RecentSearchesFocusDropdown from "@/components/queryHistory/RecentSearchesFocusDropdown.vue";
import {
useRecentSearches,
useRecentSearchesList,
} from "@/composables/queryHistory/useRecentSearches";
import { useLogsHistoryRecorder } from "@/composables/queryHistory/useLogsHistoryRecorder";
import {
recentQueryList,
recentsOpenAfterEdit,
routeEditorKeyToRecents,
type RestoreTime,
} from "@/utils/queryHistory/recentSearches";
import { openRecentOnItsPage } from "@/utils/queryHistory/routes";
import {
isUntouchedLogsQuery,
logsExampleQueries,
logsRouteQuery,
requestLogsReapply,
} from "@/utils/queryHistory/logs";
import type { HistoryStatus, QueryHistoryEntry } from "@/services/query_history";
import OSplitter from "@/lib/core/Splitter/OSplitter.vue";
import useLogs from "@/composables/useLogs";
import { useToolbarResponsive } from "@/composables/useToolbarResponsive";
@@ -1757,6 +1806,9 @@ import { useEnterpriseUpgradeDialog } from "@/composables/useEnterpriseUpgradeDi
import { checkFeatureAccess, buildFeatureGateContext } from "@/utils/enterpriseFeatures";
// Unified QueryEditor for main query editor (with built-in AI bar)
const UnifiedQueryEditor = defineAsyncComponent(() => import("@/components/QueryEditor.vue"));
const RECENT_SEARCHES_LAYERS =
'[data-test="recent-searches-popover"], [data-test="recent-searches-drawer"]';
const EDITOR_POINTER_FOCUS_MS = 1000;
import AutoRefreshInterval from "@/components/AutoRefreshInterval.vue";
import useSqlSuggestions from "@/composables/useSuggestions";
@@ -1947,6 +1999,9 @@ export default defineComponent({
OSwitch,
OTree,
SavedViewsListDialog,
RecentSearchesButton,
RecentSearchesDrawer,
RecentSearchesFocusDropdown,
},
emits: [
"searchdata",
@@ -2217,6 +2272,10 @@ export default defineComponent({
const { isStreamExists, isStreamFetched, getStreams, getStream } = useStreams(t);
const queryEditorRef = ref(null);
const queryEditorBoxRef = ref<HTMLElement | null>(null);
const recentFocusRef = ref<InstanceType<typeof RecentSearchesFocusDropdown> | null>(null);
const recentDrawerRef = ref<InstanceType<typeof RecentSearchesDrawer> | null>(null);
const recentFocusOpen = ref(false);
const syntaxGuideRef = ref(null);
const {
@@ -2244,10 +2303,18 @@ export default defineComponent({
const onQueryEditorFocus = () => {
searchObj.meta.queryEditorPlaceholderFlag = false;
_sqlOnFocus();
openRecentOnFocus();
};
const focusEditor = () => queryEditorRef.value?.focus?.();
// Escape in the list hands focus back to the editor, and that focus must not reopen the list.
let recentFocusDismissed = false;
const dismissRecentFocus = () => {
recentFocusDismissed = true;
focusEditor();
};
const handleQueryEditorBlur = async () => {
searchObj.meta.queryEditorPlaceholderFlag = true;
await _sqlOnBlur();
@@ -2962,10 +3029,11 @@ export default defineComponent({
}
autoRun.engine.reconcileEditorDirty();
};
// Esc first closes an open recent-searches list or drawer; only the next one leaves full-screen.
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
isFocused.value = false;
}
if (event.key !== "Escape") return;
if (document.querySelector(RECENT_SEARCHES_LAYERS)) return;
isFocused.value = false;
};
// DateTime.setCustomDate keeps second-precision local date/time strings; a zoom is matched on those.
@@ -4424,9 +4492,90 @@ export default defineComponent({
t,
);
const onEditorUserEdit = () => {
const onEditorUserEdit = (isBlank = false, wasBlank = false) => {
recentFocusDismissed = false;
autoRun.engine.markEditorDirty();
updateFreeTextPreview();
recentFocusOpen.value = recentsOpenAfterEdit(
recentFocusOpen.value,
wasBlank,
recentSearchesEnabled.value && isBlank,
);
};
useLogsHistoryRecorder();
const { enabled: recentSearchesEnabled } = useRecentSearches();
const recentListActive = ref(false);
const recentList = useRecentSearchesList("logs", recentListActive);
const recentQueries = computed(() =>
recentSearchesEnabled.value ? recentQueryList(recentList.data.value ?? []) : [],
);
const recentExamples = computed(() =>
logsExampleQueries(
String(searchObj.data.stream.selectedStream?.[0] ?? ""),
!!searchObj.meta.sqlMode,
),
);
let lastEditorPointer = 0;
const noteEditorPointer = () => {
lastEditorPointer = Date.now();
};
function openRecentOnFocus() {
if (recentFocusDismissed) {
recentFocusDismissed = false;
return;
}
if (!recentSearchesEnabled.value) return;
// Full-screen opens it for a click into the editor only, so a scripted focus keeps Esc collapsing.
if (isFocused.value && Date.now() - lastEditorPointer > EDITOR_POINTER_FOCUS_MS) return;
recentListActive.value = true;
const text = String(searchObj.data.editorValue ?? searchObj.data.query ?? "");
if (isUntouchedLogsQuery(text, !!searchObj.meta.sqlMode)) recentFocusOpen.value = true;
}
// Capture phase: the editor's Escape binding stops the event, and that binding must still run.
const onRecentEditorKeydown = (e: KeyboardEvent) => {
if (!recentFocusOpen.value) return;
// In full-screen the first Esc only closes the list; the editor's own Esc would also collapse it.
if (isFocused.value && e.key === "Escape") {
recentFocusOpen.value = false;
e.preventDefault();
e.stopPropagation();
return;
}
const text = String(searchObj.data.editorValue ?? searchObj.data.query ?? "");
routeEditorKeyToRecents(
e,
recentFocusRef.value,
() => (recentFocusOpen.value = false),
!text.trim(),
);
};
const openRecentSearches = (status: HistoryStatus | null = null) => {
if (!recentSearchesEnabled.value) return false;
recentDrawerRef.value?.show(status);
return true;
};
const restoreRecent = (entry: QueryHistoryEntry, when: RestoreTime, run: boolean) => {
const orgId = store.state.selectedOrganization.identifier;
if (entry.source !== "logs") {
void openRecentOnItsPage(router, entry, when, orgId, run);
return;
}
router
.push({ path: "/logs", query: logsRouteQuery(entry, when, orgId, false) })
.finally(() => requestLogsReapply(run));
};
const applyRecentExample = (query: string) => {
searchObj.data.query = query;
searchObj.data.editorValue = query;
updateQuery();
focusEditor();
};
const cancelRun = () => {
@@ -4976,6 +5125,7 @@ export default defineComponent({
searchObj,
queryEditorRef,
focusEditor,
dismissRecentFocus,
syntaxGuideRef,
confirmDialogVisible,
confirmCallback,
@@ -5009,6 +5159,17 @@ export default defineComponent({
handleRunQueryFn,
onQueryEditorFocus,
handleQueryEditorBlur,
queryEditorBoxRef,
recentFocusRef,
recentDrawerRef,
recentFocusOpen,
recentQueries,
recentExamples,
onRecentEditorKeydown,
noteEditorPointer,
openRecentSearches,
restoreRecent,
applyRecentExample,
autoCompleteKeywords,
autoCompleteSuggestions,
effectiveKeywords,
+196 -6
View File
@@ -15,8 +15,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { inject, reactive } from "vue";
import { inject, nextTick, reactive, type SetupContext } from "vue";
import { encodeMetricsConfig, getMetricsConfig } from "@/composables/metrics/metricsUrlState";
import { metricsRestore } from "@/utils/queryHistory/metrics";
import { openRecentOnItsPage, recentSearchRoute } from "@/utils/queryHistory/routes";
import type { QueryHistoryEntry } from "@/services/query_history";
import { landingRouter } from "@/test/unit/helpers/recentLanding";
// Only an explicit Run writes history; auto-refresh, deep links and refresh share runQuery.
@@ -43,7 +47,7 @@ const mockStore = vi.hoisted(() => ({
state: {
theme: "light",
selectedOrganization: { identifier: "org1" },
zoConfig: { min_auto_refresh_interval: 5 },
zoConfig: { min_auto_refresh_interval: 5, query_history_enabled: true },
},
}));
vi.mock("vuex", async (importOriginal) => ({
@@ -81,7 +85,20 @@ vi.mock("@/composables/dashboard/useDefaultPanelFields", () => ({
import MetricsIndex from "./Index.vue";
const editorRunQuery = vi.fn();
interface PanelRun {
state: { panels: Record<string, boolean> } | null;
emitError: ((e: unknown) => void) | null;
}
const panelRun: PanelRun = { state: null, emitError: null };
const editorRunQuery = vi.fn(() => {
if (panelRun.state) panelRun.state.panels.p1 = true;
});
const finishRun = async (error?: { message: string; code: string | number }) => {
if (error) panelRun.emitError?.(error);
if (panelRun.state) panelRun.state.panels.p1 = false;
await flushPromises();
await nextTick();
};
type RunQuery = (withoutCache?: boolean) => void;
const injected = { runQuery: null as RunQuery | null };
const picker = {
@@ -119,8 +136,11 @@ const mountIndex = () =>
template: '<div data-test="metrics-history" />',
},
PanelEditor: {
setup: (_: any, { expose }: any) => {
emits: ["chartApiError", "renderer-error"],
setup: (_: unknown, { expose, emit }: SetupContext) => {
injected.runQuery = inject<RunQuery | null>("runQuery", null);
panelRun.state = inject<PanelRun["state"]>("variablesAndPanelsDataLoadingState", null);
panelRun.emitError = (e: unknown) => emit("renderer-error", e);
expose({ runQuery: editorRunQuery });
return {};
},
@@ -162,8 +182,10 @@ describe("Metrics editor — query history", () => {
await wrapper.find('[data-test="metrics-apply"]').trigger("click");
await flushPromises();
expect(editorRunQuery).toHaveBeenCalled();
expect(api.record).not.toHaveBeenCalled();
await finishRun();
expect(api.record).toHaveBeenCalledTimes(1);
const [org, body] = api.record.mock.calls[0];
expect(org).toBe("org1");
@@ -178,6 +200,7 @@ describe("Metrics editor — query history", () => {
shortcuts.handlers.metricsRunQuery();
await flushPromises();
await finishRun();
expect(api.record).toHaveBeenCalledTimes(1);
});
@@ -187,6 +210,7 @@ describe("Metrics editor — query history", () => {
injected.runQuery?.(false);
await flushPromises();
await finishRun();
expect(editorRunQuery).toHaveBeenCalledTimes(1);
expect(api.record).toHaveBeenCalledTimes(1);
@@ -199,6 +223,7 @@ describe("Metrics editor — query history", () => {
shortcuts.handlers.metricsRefresh();
await wrapper.find('[data-test="metrics-auto-refresh"]').trigger("click");
await flushPromises();
await finishRun();
expect(editorRunQuery).toHaveBeenCalledTimes(2);
expect(api.record).not.toHaveBeenCalled();
@@ -208,11 +233,61 @@ describe("Metrics editor — query history", () => {
route.query = { metrics_data: encodeMetricsConfig(getMetricsConfig(makePanel())) };
mountIndex();
await flushPromises();
await finishRun();
expect(editorRunQuery).toHaveBeenCalled();
expect(api.record).not.toHaveBeenCalled();
});
describe("a recent search restored from another page", () => {
const entry = (): QueryHistoryEntry => ({
id: "m",
source: "metrics",
query: "up",
query_key: "k",
streams: ["up"],
context: {
metrics_data: encodeMetricsConfig(getMetricsConfig(makePanel())),
time_range: { period: "15m" },
},
status: "ok",
starred: false,
run_count: 1,
created_at: 1,
updated_at: 2,
});
const land = async (run: boolean) => {
await openRecentOnItsPage(landingRouter().router, entry(), "now", "org1", run);
route.query = recentSearchRoute(entry(), "now", "org1", run ? "run" : "load")!.query;
const wrapper = mountIndex();
await flushPromises();
await finishRun();
return wrapper;
};
it("runs and records it when it was restored with Run", async () => {
await land(true);
expect(editorRunQuery).toHaveBeenCalledTimes(1);
expect(api.record).toHaveBeenCalledTimes(1);
});
it("loads it without running or recording when it was restored with Load without running", async () => {
await land(false);
expect(editorRunQuery).not.toHaveBeenCalled();
expect(api.record).not.toHaveBeenCalled();
});
it("uses the restore once: reopening the same URL afterwards runs without recording", async () => {
const first = await land(true);
first.unmount();
mountIndex();
await flushPromises();
await finishRun();
expect(editorRunQuery).toHaveBeenCalledTimes(2);
expect(api.record).toHaveBeenCalledTimes(1);
});
});
it("records nothing when the query fails validation", async () => {
validatePanel.mockImplementation((errors: string[]) => errors.push("bad"));
const wrapper = mountIndex();
@@ -230,12 +305,99 @@ describe("Metrics editor — query history", () => {
await wrapper.find('[data-test="metrics-apply"]').trigger("click");
await flushPromises();
await finishRun();
expect(editorRunQuery).toHaveBeenCalled();
expect(consoleError).toHaveBeenCalled();
expect(showErrorNotification).not.toHaveBeenCalled();
});
it("records the run's window in microseconds and reopens it at that window", async () => {
const startUs = 1_761_500_000_000_000;
const endUs = startUs + 3_600_000_000;
picker.getConsumableDateTime.mockReturnValue({ startTime: startUs, endTime: endUs });
const wrapper = mountIndex();
await flushPromises();
await wrapper.find('[data-test="metrics-apply"]').trigger("click");
await flushPromises();
await finishRun();
const body = api.record.mock.calls[0][1];
expect(body.context.time).toMatchObject({ start_time: startUs, end_time: endUs });
const reopened = metricsRestore(
{
...body,
id: "r",
query_key: "k",
starred: false,
run_count: 1,
created_at: 1,
updated_at: 2,
},
"original",
);
wrapper
.findComponent('[data-test="metrics-history"]')
.vm.$emit("load", { ...reopened, run: false });
await flushPromises();
const saved = picker.setSavedDate.mock.calls.at(-1)?.[0];
expect(saved).toEqual({ type: "absolute", startTime: startUs, endTime: endUs });
// DateTime.setSavedDate reads these as microseconds and divides by 1000 for its labels.
expect(new Date(saved.startTime / 1000).getUTCFullYear()).toBe(2025);
picker.getConsumableDateTime.mockReturnValue({ startTime: 1_000_000, endTime: 2_000_000 });
});
it("names the metric the custom query selects, not the stream picker's last choice", async () => {
panel.current.data.queries = [
{
query: "sum(rate(http_requests_total[5m])) by (service)",
customQuery: true,
fields: { stream_type: "metrics", stream: "node_memory_MemAvailable_bytes" },
},
];
const wrapper = mountIndex();
await flushPromises();
await wrapper.find('[data-test="metrics-apply"]').trigger("click");
await flushPromises();
await finishRun();
expect(api.record.mock.calls[0][1].streams).toEqual(["http_requests_total"]);
});
it("records a query error as failed, with its message", async () => {
const wrapper = mountIndex();
await flushPromises();
await wrapper.find('[data-test="metrics-apply"]').trigger("click");
await flushPromises();
await finishRun({ message: "parse error: unexpected <EOF>", code: 400 });
expect(api.record).toHaveBeenCalledTimes(1);
expect(api.record.mock.calls[0][1]).toMatchObject({
status: "error",
context: { error: "parse error: unexpected <EOF>" },
});
});
it("records a timeout as ok with a warning", async () => {
const wrapper = mountIndex();
await flushPromises();
await wrapper.find('[data-test="metrics-apply"]').trigger("click");
await flushPromises();
await finishRun({ message: "timed out", code: 20010 });
expect(api.record.mock.calls[0][1]).toMatchObject({
status: "ok",
context: { warning: "timed out" },
});
});
it("records nothing for a cancelled run", async () => {
const wrapper = mountIndex();
await flushPromises();
await wrapper.find('[data-test="metrics-apply"]').trigger("click");
await flushPromises();
await finishRun({ message: "query cancelled", code: 20009 });
expect(api.record).not.toHaveBeenCalled();
});
describe("loading an entry applies it live", () => {
const blobFor = (type: string, query: string) => {
const p = makePanel();
@@ -280,7 +442,35 @@ describe("Metrics editor — query history", () => {
endTime: 9000,
});
expect(editorRunQuery).toHaveBeenCalledTimes(2);
// Loading an entry is not a new run of it.
});
it("records a re-run from history once it settles, like a Run", async () => {
const wrapper = mountIndex();
await flushPromises();
wrapper.findComponent('[data-test="metrics-history"]').vm.$emit("load", {
metricsData: blobFor("bar", "sum(a)"),
timeRange: { valueType: "relative", relativeTimePeriod: "6h" },
run: true,
});
await flushPromises();
await finishRun();
expect(editorRunQuery).toHaveBeenCalledTimes(1);
expect(api.record).toHaveBeenCalledTimes(1);
expect(api.record.mock.calls[0][1]).toMatchObject({ source: "metrics", query: "sum(a)" });
});
it("loads without running or recording when asked to load only", async () => {
const wrapper = mountIndex();
await flushPromises();
const data = panel.current.data;
wrapper.findComponent('[data-test="metrics-history"]').vm.$emit("load", {
metricsData: blobFor("bar", "sum(a)"),
timeRange: { valueType: "relative", relativeTimePeriod: "6h" },
run: false,
});
await flushPromises();
expect(data.queries.map((q: { query: string }) => q.query)).toEqual(["sum(a)"]);
expect(editorRunQuery).not.toHaveBeenCalled();
expect(api.record).not.toHaveBeenCalled();
});
});
+57 -12
View File
@@ -58,8 +58,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
shortcut-id="metricsCopyUrl"
class="h-8"
/>
<QueryHistoryDrawer @load="onHistoryLoad" />
<QueryHistoryDrawer ref="historyDrawerRef" @load="onHistoryLoad" />
<RecentSearchesFocusDropdown
ref="recentDropdownRef"
v-model:open="recentEditor.open.value"
source="metrics"
:anchor="recentEditor.anchor.value"
@run="(entry, when) => historyDrawerRef?.restore(entry, when, true)"
@load="(entry, when) => historyDrawerRef?.restore(entry, when, false)"
@view-all="(status) => historyDrawerRef?.show(status)"
/>
<template v-if="!['html', 'markdown'].includes(dashboardPanelData.data.type)">
<RecentSearchesButton
source="metrics"
data-test="metrics-history-btn"
@run="(entry, when) => historyDrawerRef?.restore(entry, when, true)"
@load="(entry, when) => historyDrawerRef?.restore(entry, when, false)"
@view-all="(status) => historyDrawerRef?.show(status)"
/>
<OButton
v-if="config.isEnterprise == 'true' && searchRequestTraceIds.length"
variant="cancel-query"
@@ -101,6 +117,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:selectedDateTime="dashboardPanelData.meta.dateTime"
@addToDashboard="addToDashboard"
@chartApiError="handleChartApiError"
@renderer-error="historyRecorder.onPanelError"
@dataZoom="onDataZoom"
/>
@@ -158,6 +175,10 @@ import {
import { useQueryHistoryRecorder } from "@/composables/metrics/useQueryHistoryRecorder";
import { pickerSavedDate } from "@/utils/metrics/queryHistory";
import QueryHistoryDrawer from "./QueryHistoryDrawer.vue";
import RecentSearchesButton from "@/components/queryHistory/RecentSearchesButton.vue";
import RecentSearchesFocusDropdown from "@/components/queryHistory/RecentSearchesFocusDropdown.vue";
import { useRecentSearchesEditorHost } from "@/composables/queryHistory/useRecentSearchesEditorHost";
import { metricsEditorIsEmpty, metricsEditorIsEmptyWith } from "@/utils/queryHistory/metrics";
import analytics from "@/services/product_analytics";
import {
queryParamsToSelectedDate,
@@ -176,6 +197,7 @@ import OButton from "@/lib/core/Button/OButton.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { isInputFocused } from "@/utils/keyboardShortcuts";
import { takeRecentLanding } from "@/utils/queryHistory/routes";
export default defineComponent({
name: "Metrics",
@@ -193,6 +215,8 @@ export default defineComponent({
OTooltip,
ShareButton,
QueryHistoryDrawer,
RecentSearchesButton,
RecentSearchesFocusDropdown,
},
setup() {
provide("dashboardPanelDataPageKey", "metrics");
@@ -336,9 +360,12 @@ export default defineComponent({
const isPanelConfigChanged = ref(false);
// Reset before children mount so FieldList sees stream_type=metrics (avoids a spurious logs stream fetch).
// A restore from recent searches runs only on request, and records then; any other landing runs unrecorded.
let landing: { run: boolean } | null = null;
onBeforeMount(() => {
applyMetricsDefaults();
pendingAutoRun = hydrateFromUrl();
landing = takeRecentLanding("metrics");
pendingAutoRun = hydrateFromUrl() && (landing?.run ?? true);
});
onMounted(async () => {
@@ -355,7 +382,8 @@ export default defineComponent({
if (pendingAutoRun) {
pendingAutoRun = false;
updateDateTime();
runQuery();
if (landing?.run) onUserRun();
else runQuery();
}
});
@@ -444,23 +472,33 @@ export default defineComponent({
return true;
};
const { record } = useQueryHistoryRecorder();
/** An explicit Run (button or shortcut): runs, then records a valid query. */
/** An explicit Run (button, shortcut or a history re-run): runs, then records a valid query. */
const onUserRun = () => {
if (runQuery()) record(dashboardPanelData, selectedDate.value);
if (runQuery()) historyRecorder.record(dashboardPanelData, selectedDate.value);
};
/** The editor hydrates only on mount, so an entry replaces the panel in place and reruns. */
const applyPanelData = (metricsData: string, timeRange: SelectedDate) => {
/** The editor hydrates only on mount, so an entry replaces the panel in place; a re-run is a user Run. */
const applyPanelData = (metricsData: string, timeRange: SelectedDate, run = true) => {
if (!applyMetricsBlob(metricsData, dashboardPanelData)) return;
selectedDate.value = timeRange;
dateTimePickerRef.value?.setSavedDate?.(pickerSavedDate(timeRange));
runQuery();
if (run) onUserRun();
};
const onHistoryLoad = (entry: { metricsData: string; timeRange: SelectedDate }) => {
applyPanelData(entry.metricsData, entry.timeRange);
const historyDrawerRef = ref<InstanceType<typeof QueryHistoryDrawer> | null>(null);
// One editor takes one query, so multi-query rows stay out of its clock rows.
const recentEditor = useRecentSearchesEditorHost("metrics", {
opensOnFocus: () => metricsEditorIsEmpty(dashboardPanelData),
emptyWith: (isBlank) => metricsEditorIsEmptyWith(dashboardPanelData, isBlank),
acceptsQuery: (query) => !query.includes("\n"),
});
const onHistoryLoad = (entry: {
metricsData: string;
timeRange: SelectedDate;
run?: boolean;
}) => {
applyPanelData(entry.metricsData, entry.timeRange, entry.run !== false);
try {
analytics.track("metrics_query_history_loaded", {
org_id: store.state.selectedOrganization?.identifier,
@@ -588,6 +626,7 @@ export default defineComponent({
const { traceIdRef, cancelQuery } = useCancelQuery(t);
const cancelAddPanelQuery = () => {
historyRecorder.cancel();
traceIdRef.value = searchRequestTraceIds.value;
cancelQuery();
};
@@ -599,6 +638,8 @@ export default defineComponent({
disable.value = panelsValues.some((item: any) => item === true);
});
const historyRecorder = useQueryHistoryRecorder(() => disable.value);
const isRunQueryPending = computed(() => {
if (!isRunQueryArmed.value || disable.value || appliedSignature.value === null) return false;
return panelPayloadSignature(dashboardPanelData.data) !== appliedSignature.value;
@@ -699,6 +740,10 @@ export default defineComponent({
onUserRun,
applyPanelData,
onHistoryLoad,
historyDrawerRef,
historyRecorder,
recentEditor,
recentDropdownRef: recentEditor.dropdownRef,
dashboardPanelData,
chartData,
editMode,
@@ -13,17 +13,17 @@
// 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, vi, beforeEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { enableAutoUnmount, mount, flushPromises } from "@vue/test-utils";
import { ref } from "vue";
import store from "@/test/unit/helpers/store";
import i18n from "@/locales";
const api = vi.hoisted(() => ({
list: vi.fn(),
record: vi.fn(),
star: vi.fn(),
remove: vi.fn(),
clear: vi.fn(),
}));
vi.mock("@/services/query_history", () => ({ default: api }));
@@ -32,22 +32,44 @@ vi.mock("@/composables/useBreakpoint", () => ({
default: () => ({ isMobile: ref(breakpoint.mobile) }),
}));
const push = vi.hoisted(() => vi.fn(() => Promise.resolve()));
vi.mock("vue-router", () => ({
useRouter: () => ({ push, resolve: () => ({ href: "/web/metrics" }) }),
}));
import QueryHistoryDrawer from "./QueryHistoryDrawer.vue";
const ENTRIES = [
enableAutoUnmount(afterEach);
const NOW_US = Date.now() * 1000;
// v1.1.x rows: no key, no window; the migration gave them source metrics and updated_at.
const LEGACY = [
{
id: "e2",
source: "metrics",
query: "rate(x[5m])",
query_key: null,
streams: [],
context: { metrics_data: "blob-2", time_range: { period: "6h" }, chart_type: "bar" },
status: "ok",
starred: true,
created_at: 2_000_000,
run_count: 1,
created_at: NOW_US,
updated_at: NOW_US,
},
{
id: "e1",
source: "metrics",
query: "up",
query_key: null,
streams: [],
context: { metrics_data: "blob-1", time_range: { from: 1000, to: 2000 }, chart_type: "line" },
status: "ok",
starred: false,
created_at: 1_000_000,
run_count: 1,
created_at: NOW_US - 1,
updated_at: NOW_US - 1,
},
];
@@ -58,240 +80,122 @@ const mountDrawer = () =>
stubs: {
ODrawer: {
props: ["open", "size"],
emits: ["update:open"],
template: `<div>
<span @click="$emit('update:open', true)"><slot name="trigger" /></span>
<div v-if="open" data-test="drawer" :data-size="size"><slot /></div>
</div>`,
template: `<div v-if="open" data-stub="drawer" :data-size="size"><slot /></div>`,
},
OTable: {
props: ["data"],
emits: ["row-click"],
template: `<div>
<div v-for="row in data" :key="row.id" :data-test="'row-' + row.id" @click="$emit('row-click', row, $event)">
<slot name="cell-query" :row="row" />
<slot name="cell-actions" :row="row" />
</div>
</div>`,
ODropdown: { template: `<div @click.stop><slot name="trigger" /><slot /></div>` },
ODropdownItem: {
emits: ["select"],
template: `<button v-bind="$attrs" @click="$emit('select')"><slot /></button>`,
},
OTimeCell: true,
OTooltip: true,
OSearchInput: {
props: ["modelValue"],
emits: ["update:modelValue"],
template:
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
},
OSwitch: {
props: ["modelValue"],
emits: ["update:modelValue"],
template:
'<input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)" />',
},
},
},
});
const open = async (wrapper: any) => {
await wrapper.find('[data-test="metrics-history-btn"]').trigger("click");
const open = async (wrapper: ReturnType<typeof mountDrawer>) => {
(wrapper.vm as unknown as { show: () => void }).show();
await flushPromises();
};
describe("QueryHistoryDrawer", () => {
describe("QueryHistoryDrawer (Metrics on the shared drawer)", () => {
beforeEach(() => {
vi.clearAllMocks();
breakpoint.mobile = false;
api.list.mockResolvedValue({ data: ENTRIES });
api.star.mockResolvedValue({ data: {} });
api.remove.mockResolvedValue({ data: {} });
api.list.mockResolvedValue({ data: LEGACY });
});
it("fetches nothing until opened, then lists the entries in server (newest-first) order", async () => {
it("fetches nothing until opened, then lists this page's rows newest first", async () => {
const wrapper = mountDrawer();
await flushPromises();
expect(api.list).not.toHaveBeenCalled();
await open(wrapper);
expect(api.list).toHaveBeenCalledWith("default", { limit: 50, offset: 0 });
const rows = wrapper.findAll('[data-test^="row-"]').map((r) => r.attributes("data-test"));
expect(rows).toEqual(["row-e2", "row-e1"]);
expect(api.list).toHaveBeenCalledWith("default", { source: "metrics", limit: 50, offset: 0 });
const rows = wrapper
.findAll('[data-test^="recent-searches-drawer-row-"]')
.map((r) => r.attributes("data-test"));
expect(rows).toEqual(["recent-searches-drawer-row-e2", "recent-searches-drawer-row-e1"]);
});
it("asks the server for starred entries only when the toggle is on", async () => {
it("re-runs a v1.1.x row with its stored panel and range", async () => {
const wrapper = mountDrawer();
await open(wrapper);
await wrapper.find('[data-test="metrics-history-starred-only"]').setValue(true);
await wrapper.find('[data-test="recent-searches-drawer-row-e2"]').trigger("click");
await flushPromises();
expect(api.list).toHaveBeenLastCalledWith("default", { starred: true, limit: 50, offset: 0 });
expect(wrapper.emitted("load")?.[0]?.[0]).toEqual({
metricsData: "blob-2",
timeRange: expect.objectContaining({ valueType: "relative", relativeTimePeriod: "6h" }),
run: true,
});
expect(wrapper.find('[data-stub="drawer"]').exists()).toBe(false);
});
it("searches on the server", async () => {
it("loads a v1.1.x absolute row without running", async () => {
const wrapper = mountDrawer();
await open(wrapper);
await wrapper.find('[data-test="metrics-history-search"]').setValue("rate");
await wrapper.find('[data-test="recent-searches-load-e1"]').trigger("click");
await flushPromises();
expect(api.list).toHaveBeenLastCalledWith("default", { q: "rate", limit: 50, offset: 0 });
expect(wrapper.emitted("load")?.[0]?.[0]).toEqual({
metricsData: "blob-1",
timeRange: expect.objectContaining({ valueType: "absolute", startTime: 1000, endTime: 2000 }),
run: false,
});
});
it("stars, unstars and deletes through the entry routes", async () => {
api.star.mockResolvedValue({ data: {} });
api.remove.mockResolvedValue({ data: {} });
const wrapper = mountDrawer();
await open(wrapper);
await wrapper.find('[data-test="metrics-history-star-e1"]').trigger("click");
await wrapper.find('[data-test="recent-searches-star-e1"]').trigger("click");
await flushPromises();
expect(api.star).toHaveBeenCalledWith("default", "e1", true);
await wrapper.find('[data-test="metrics-history-star-e2"]').trigger("click");
await wrapper.find('[data-test="recent-searches-star-e2"]').trigger("click");
await flushPromises();
expect(api.star).toHaveBeenCalledWith("default", "e2", false);
await wrapper.find('[data-test="metrics-history-delete-e1"]').trigger("click");
await flushPromises();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
await wrapper.find('[data-test="recent-searches-delete-e1"]').trigger("click");
vi.advanceTimersByTime(6000);
await flushPromises();
} finally {
vi.useRealTimers();
}
expect(api.remove).toHaveBeenCalledWith("default", "e1");
// A row action is not a load.
expect(wrapper.emitted("load")).toBeUndefined();
});
it("loads a clicked entry and closes", async () => {
it("opens another page's row on that page instead of loading it here", async () => {
const wrapper = mountDrawer();
await open(wrapper);
await wrapper.find('[data-test="row-e2"]').trigger("click");
await flushPromises();
expect(wrapper.emitted("load")?.[0]?.[0]).toMatchObject({
metricsData: "blob-2",
timeRange: { valueType: "relative", relativeTimePeriod: "6h" },
});
expect(wrapper.find('[data-test="drawer"]').exists()).toBe(false);
});
describe("loading past the first page", () => {
const page = (from: number, count: number) =>
Array.from({ length: count }, (_, i) => ({
...ENTRIES[1],
id: `p${from + i}`,
created_at: 1_000_000 - (from + i),
}));
const loadMore = (wrapper: any) => wrapper.find('[data-test="metrics-history-load-more"]');
it("fetches the next page by offset and appends it", async () => {
api.list.mockImplementation((_org: string, params: any) =>
Promise.resolve({ data: params.offset ? page(50, 7) : page(0, 50) }),
);
const wrapper = mountDrawer();
await open(wrapper);
expect(wrapper.findAll('[data-test^="row-"]')).toHaveLength(50);
await loadMore(wrapper).trigger("click");
await flushPromises();
expect(api.list).toHaveBeenLastCalledWith("default", { limit: 50, offset: 50 });
const rows = wrapper.findAll('[data-test^="row-"]').map((r) => r.attributes("data-test"));
expect(rows).toHaveLength(57);
expect(rows[0]).toBe("row-p0");
expect(rows[50]).toBe("row-p50");
// The short page was the last one.
expect(loadMore(wrapper).exists()).toBe(false);
});
it("offers no more when the first page is already short", async () => {
const wrapper = mountDrawer();
await open(wrapper);
expect(loadMore(wrapper).exists()).toBe(false);
});
(wrapper.vm as unknown as { restore: (...args: unknown[]) => void }).restore(
{
...LEGACY[0],
id: "logs-row",
source: "logs",
query: "level = 'error'",
streams: ["k8s"],
context: { page: { stream: "k8s" } },
},
"now",
true,
);
expect(push).toHaveBeenCalledWith(
expect.objectContaining({
path: "/logs",
query: expect.objectContaining({ type: "search_history_re_apply" }),
}),
);
expect(wrapper.emitted("load")).toBeUndefined();
});
it("goes full screen on mobile", async () => {
breakpoint.mobile = true;
const wrapper = mountDrawer();
await open(wrapper);
expect(wrapper.find('[data-test="drawer"]').attributes("data-size")).toBe("full");
});
});
// The real OTable: it owns the #empty slot and the cell slots, so a stub would hide a wrong slot.
describe("QueryHistoryDrawer with the real OTable", () => {
const DrawerStub = {
props: ["open", "size"],
emits: ["update:open"],
template: `<div>
<span @click="$emit('update:open', true)"><slot name="trigger" /></span>
<div v-if="open" data-test="drawer"><slot /></div>
</div>`,
};
const mountReal = () =>
mount(QueryHistoryDrawer, {
global: { plugins: [i18n, store], stubs: { ODrawer: DrawerStub } },
});
const emptyTitle = (wrapper: any) => wrapper.find('[data-test="metrics-history-empty"]').text();
beforeEach(() => {
vi.clearAllMocks();
breakpoint.mobile = false;
api.list.mockResolvedValue({ data: [] });
});
it("says there are no queries yet when nothing filters the list", async () => {
const wrapper = mountReal();
await open(wrapper);
await vi.waitFor(() => expect(emptyTitle(wrapper)).toContain("No queries yet"));
});
it("says no queries match when a search finds nothing", async () => {
const wrapper = mountReal();
await open(wrapper);
const search = wrapper.find('[data-test="metrics-history-search"]');
await (search.element.tagName === "INPUT" ? search : search.find("input")).setValue("nomatch");
await vi.waitFor(() =>
expect(api.list).toHaveBeenCalledWith("default", expect.objectContaining({ q: "nomatch" })),
);
await vi.waitFor(() =>
expect(emptyTitle(wrapper)).toContain("No queries match the search or starred filter"),
);
});
it("folds the row actions into a kebab on a phone, whose Star and Delete reach the entry routes", async () => {
breakpoint.mobile = true;
api.list.mockResolvedValue({ data: ENTRIES });
api.star.mockResolvedValue({ data: {} });
api.remove.mockResolvedValue({ data: {} });
const wrapper = mount(QueryHistoryDrawer, {
attachTo: document.body,
global: { plugins: [i18n, store], stubs: { ODrawer: DrawerStub } },
});
await open(wrapper);
const more = () => wrapper.find('[data-test="metrics-history-more-e1"]');
await vi.waitFor(() => expect(more().exists()).toBe(true));
expect(more().classes()).toContain("md:hidden");
expect(wrapper.find('[data-test="metrics-history-delete-e1"]').classes()).toContain(
"max-md:hidden",
);
expect(wrapper.find('[data-test="metrics-history-star-e1"]').classes()).toContain(
"max-md:hidden",
);
// The menu is portalled to <body>, outside the wrapper.
const pick = async (dataTest: string) => {
await more().trigger("keydown", { key: "Enter" });
await flushPromises();
const item = await vi.waitFor(() => {
const el = document.querySelector<HTMLElement>(`[data-test="${dataTest}"]`);
expect(el).not.toBeNull();
return el!;
});
expect(item.classList.contains("md:hidden")).toBe(false);
item.click();
await flushPromises();
};
await pick("metrics-history-star-e1-menu");
expect(api.star).toHaveBeenCalledWith("default", "e1", true);
await pick("metrics-history-delete-e1-menu");
expect(api.remove).toHaveBeenCalledWith("default", "e1");
expect(wrapper.emitted("load")).toBeUndefined();
wrapper.unmount();
expect(wrapper.find('[data-stub="drawer"]').attributes("data-size")).toBe("full");
});
});
+25 -202
View File
@@ -15,218 +15,41 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<ODrawer
v-model:open="open"
:size="isMobile ? 'full' : 'md'"
:title="t('metrics.history.title')"
bleed
data-test="metrics-history-drawer"
>
<template #trigger>
<OButton
variant="outline"
size="icon-toolbar"
icon-left="history"
:aria-label="t('metrics.history.title')"
data-test="metrics-history-btn"
>
<OTooltip :content="t('metrics.history.title')" />
</OButton>
</template>
<div class="flex h-full min-h-0 flex-col">
<div class="border-border-default flex shrink-0 items-center gap-3 border-b px-4 py-2">
<OSearchInput
v-model="search"
size="sm"
clearable
:debounce="300"
:placeholder="t('metrics.history.searchPlaceholder')"
class="min-w-0 flex-1"
data-test="metrics-history-search"
/>
<OSwitch
v-model="starredOnly"
size="sm"
:label="t('metrics.history.starredOnly')"
data-test="metrics-history-starred-only"
/>
</div>
<div class="min-h-0 flex-1">
<OTable
:data="rows"
:columns="columns"
row-key="id"
:loading="history.isPending.value && open"
:frame="false"
:default-columns="false"
:show-global-filter="false"
pagination="none"
sorting="none"
wrap
data-test="metrics-history-table"
@row-click="onLoad"
>
<template #cell-query="{ row }">
<span class="text-text-body font-mono text-xs break-all">{{ row.query }}</span>
</template>
<template #cell-created_at="{ row }">
<OTimeCell :value="row.created_at" unit="us" mode="relative" />
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<OButton
variant="ghost"
size="icon-sm"
class="max-md:hidden"
:icon-left="row.starred ? 'star' : 'star-outline'"
:aria-label="row.starred ? t('metrics.history.unstar') : t('metrics.history.star')"
:data-test="`metrics-history-star-${row.id}`"
@click.stop="toggleStar(row)"
>
<OTooltip
:content="row.starred ? t('metrics.history.unstar') : t('metrics.history.star')"
/>
</OButton>
<OButton
variant="ghost-destructive"
size="icon-sm"
class="max-md:hidden"
icon-left="delete"
:aria-label="t('common.delete')"
:data-test="`metrics-history-delete-${row.id}`"
@click.stop="deleteEntry.mutate(row.id)"
>
<OTooltip :content="t('common.delete')" />
</OButton>
<ODropdown side="bottom" align="end">
<template #trigger>
<OButton
variant="ghost"
size="icon-xs-sq"
icon-left="more-vert"
class="md:hidden"
:aria-label="t('common.more')"
:data-test="`metrics-history-more-${row.id}`"
@click.stop
/>
</template>
<ODropdownItem
:icon-left="row.starred ? 'star' : 'star-outline'"
:data-test="`metrics-history-star-${row.id}-menu`"
@select="toggleStar(row)"
>
<span>{{
row.starred ? t("metrics.history.unstar") : t("metrics.history.star")
}}</span>
</ODropdownItem>
<ODropdownItem
variant="destructive"
icon-left="delete"
:data-test="`metrics-history-delete-${row.id}-menu`"
@select="deleteEntry.mutate(row.id)"
>
<span>{{ t("common.delete") }}</span>
</ODropdownItem>
</ODropdown>
</div>
</template>
<template #empty>
<OEmptyState
size="inline"
icon="history"
:title="filtered ? t('metrics.history.noMatch') : t('metrics.history.empty')"
data-test="metrics-history-empty"
/>
</template>
</OTable>
</div>
<div
v-if="history.hasNextPage.value"
class="border-border-default flex shrink-0 justify-center border-t p-3"
>
<OButton
variant="outline"
size="sm"
:loading="history.isFetchingNextPage.value"
data-test="metrics-history-load-more"
@click="history.fetchNextPage()"
>
{{ t("metrics.history.loadMore") }}
</OButton>
</div>
</div>
</ODrawer>
<RecentSearchesDrawer ref="drawerRef" v-model:open="open" source="metrics" @restore="restore" />
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useInfiniteQuery, useMutation } from "@tanstack/vue-query";
import { useI18nTyped } from "@/types/i18n";
import useBreakpoint from "@/composables/useBreakpoint";
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useOrgId } from "@/composables/query/useOrgId";
import {
deleteQueryHistoryMutation,
queryHistoryQuery,
starQueryHistoryMutation,
} from "@/services/query_history.queries";
import type { QueryHistoryEntry } from "@/services/query_history";
import { historyEntryToLoad } from "@/utils/metrics/queryHistory";
import type { HistoryStatus, QueryHistoryEntry } from "@/services/query_history";
import type { SelectedDate } from "@/utils/dashboard/urlTimeParams";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
import OTable from "@/lib/core/Table/OTable.vue";
import OTimeCell from "@/lib/core/Table/cells/OTimeCell.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import { metricsRestore } from "@/utils/queryHistory/metrics";
import { openRecentOnItsPage } from "@/utils/queryHistory/routes";
import type { RestoreTime } from "@/utils/queryHistory/recentSearches";
import RecentSearchesDrawer from "@/components/queryHistory/RecentSearchesDrawer.vue";
const emit = defineEmits<{
load: [entry: { metricsData: string; timeRange: SelectedDate }];
load: [entry: { metricsData: string; timeRange: SelectedDate; run: boolean }];
}>();
const { t } = useI18nTyped();
const { isMobile } = useBreakpoint();
const open = defineModel<boolean>("open", { default: false });
const router = useRouter();
const orgId = useOrgId();
const drawerRef = ref<InstanceType<typeof RecentSearchesDrawer> | null>(null);
const open = ref(false);
const search = ref("");
const starredOnly = ref(false);
const history = useInfiniteQuery(() =>
Object.assign(queryHistoryQuery(orgId.value, starredOnly.value, search.value.trim()), {
enabled: open.value && !!orgId.value,
}),
);
const filtered = computed(() => !!search.value.trim() || starredOnly.value);
// The server returns newest first; the table keeps that order.
const rows = computed(() => history.data.value?.pages.flat() ?? []);
const starEntry = useMutation(() => starQueryHistoryMutation(orgId.value));
const deleteEntry = useMutation(() => deleteQueryHistoryMutation(orgId.value));
const toggleStar = (row: QueryHistoryEntry) =>
starEntry.mutate({ id: row.id, starred: !row.starred });
const columns = computed<OTableColumnDef<QueryHistoryEntry>[]>(() => [
{
id: "query",
header: t("metrics.history.query"),
accessorKey: "query",
meta: { autoWidth: true, fillRemaining: true },
},
{ id: "created_at", header: t("metrics.history.ranAt"), accessorKey: "created_at", size: 112 },
{ id: "actions", header: t("common.actions"), isAction: true, size: 80 },
]);
const onLoad = (row: QueryHistoryEntry) => {
const entry = historyEntryToLoad(row.context);
if (!entry) return;
emit("load", entry);
open.value = false;
/** Applies a row here, or opens another page's row on that page. */
const restore = (entry: QueryHistoryEntry, when: RestoreTime, run: boolean) => {
if (entry.source !== "metrics") {
void openRecentOnItsPage(router, entry, when, orgId.value, run);
return;
}
const loaded = metricsRestore(entry, when);
if (loaded) emit("load", { ...loaded, run });
};
const show = (status: HistoryStatus | null = null) => drawerRef.value?.show(status);
defineExpose({ show, restore });
</script>
@@ -1974,14 +1974,26 @@ describe("MetricsExplorer wiring", () => {
const timeRange = { valueType: "relative", relativeTimePeriod: "6h" };
wrapper
.findComponent('[data-test="metrics-history"]')
.vm.$emit("load", { metricsData: "blob", timeRange });
expect(visualizeApplyPanelData).toHaveBeenCalledWith("blob", timeRange);
.vm.$emit("load", { metricsData: "blob", timeRange, run: true });
expect(visualizeApplyPanelData).toHaveBeenCalledWith("blob", timeRange, true);
expect(analytics.track).toHaveBeenCalledWith(
"metrics_explorer_history_loaded",
expect.any(Object),
);
});
it("passes a load-only pick through without running", async () => {
const wrapper = mountExplorer();
(wrapper.vm as unknown as { setMode: (mode: string) => void }).setMode("visualize");
await wrapper.vm.$nextTick();
const timeRange = { valueType: "relative", relativeTimePeriod: "6h" };
wrapper
.findComponent('[data-test="metrics-history"]')
.vm.$emit("load", { metricsData: "blob", timeRange, run: false });
expect(visualizeApplyPanelData).toHaveBeenCalledWith("blob", timeRange, false);
});
it("the pane's range change sets the toolbar picker", async () => {
const setSavedDate = vi.fn();
const wrapper = mountExplorer({
@@ -115,7 +115,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
<!-- Only an explicit Run writes query history; Refresh does not. -->
<template v-if="mode === 'visualize'">
<QueryHistoryDrawer @load="onHistoryLoad" />
<QueryHistoryDrawer ref="historyDrawerRef" @load="onHistoryLoad" />
<RecentSearchesFocusDropdown
ref="recentDropdownRef"
v-model:open="recentEditor.open.value"
source="metrics"
:anchor="recentEditor.anchor.value"
@run="(entry, when) => historyDrawerRef?.restore(entry, when, true)"
@load="(entry, when) => historyDrawerRef?.restore(entry, when, false)"
@view-all="(status) => historyDrawerRef?.show(status)"
/>
<RecentSearchesButton
source="metrics"
data-test="metrics-history-btn"
@run="(entry, when) => historyDrawerRef?.restore(entry, when, true)"
@load="(entry, when) => historyDrawerRef?.restore(entry, when, false)"
@view-all="(status) => historyDrawerRef?.show(status)"
/>
<OButtonGroup>
<OButton
:variant="isVisualizePending ? 'pending' : 'primary'"
@@ -747,6 +763,10 @@ import FunctionConfigDialog from "./FunctionConfigDialog.vue";
import MetricDetailView from "./MetricDetailView.vue";
import ExplorerSavedViews from "./ExplorerSavedViews.vue";
import QueryHistoryDrawer from "../QueryHistoryDrawer.vue";
import RecentSearchesButton from "@/components/queryHistory/RecentSearchesButton.vue";
import RecentSearchesFocusDropdown from "@/components/queryHistory/RecentSearchesFocusDropdown.vue";
import { useRecentSearchesEditorHost } from "@/composables/queryHistory/useRecentSearchesEditorHost";
import { metricsEditorIsEmpty, metricsEditorIsEmptyWith } from "@/utils/queryHistory/metrics";
import useMetricsExplorerGrid, {
PAGE_SIZE_INCREMENT,
@@ -835,6 +855,8 @@ export default defineComponent({
MetricDetailView,
ExplorerSavedViews,
QueryHistoryDrawer,
RecentSearchesButton,
RecentSearchesFocusDropdown,
},
setup() {
const { t } = useI18nTyped();
@@ -2093,8 +2115,21 @@ export default defineComponent({
const onVisualizeRunWithoutCache = () =>
visualizeRef.value?.onUserRun?.(selectedDate.value, true);
const onHistoryLoad = (entry: { metricsData: string; timeRange: SelectedDate }) => {
visualizeRef.value?.applyPanelData?.(entry.metricsData, entry.timeRange);
const historyDrawerRef = ref<InstanceType<typeof QueryHistoryDrawer> | null>(null);
// One editor takes one query, so multi-query rows stay out of its clock rows.
const recentEditor = useRecentSearchesEditorHost("metrics", {
opensOnFocus: () => metricsEditorIsEmpty(visualizeRef.value?.dashboardPanelData),
emptyWith: (isBlank) =>
metricsEditorIsEmptyWith(visualizeRef.value?.dashboardPanelData, isBlank),
acceptsQuery: (query) => !query.includes("\n"),
});
const onHistoryLoad = (entry: {
metricsData: string;
timeRange: SelectedDate;
run?: boolean;
}) => {
visualizeRef.value?.applyPanelData?.(entry.metricsData, entry.timeRange, entry.run !== false);
track("metrics_explorer_history_loaded");
};
@@ -2363,6 +2398,9 @@ export default defineComponent({
onVisualizeRun,
onVisualizeRunWithoutCache,
onHistoryLoad,
historyDrawerRef,
recentEditor,
recentDropdownRef: recentEditor.dropdownRef,
onVisualizeTimeRange,
};
},
@@ -0,0 +1,251 @@
// 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { enableAutoUnmount, flushPromises, shallowMount } from "@vue/test-utils";
import { nextTick, reactive, ref, type Ref } from "vue";
import i18n from "@/locales";
interface EditorState {
isLoading: Ref<boolean> | null;
chartData: Ref<object> | null;
}
interface VisualizeApi {
onUserRun: (range: typeof RANGE) => void;
}
const panel = vi.hoisted(() => ({ current: null as ReturnType<typeof makePanel> | null }));
const editor = vi.hoisted((): EditorState => ({ isLoading: null, chartData: null }));
const makePanel = () =>
reactive({
data: {
type: "line",
queryType: "promql",
config: {},
queries: [
{ query: "sum(up)", customQuery: true, fields: { stream: "up", stream_type: "metrics" } },
],
},
layout: {
splitter: 20,
showFieldList: true,
showQueryBar: true,
querySplitter: 50,
currentQueryIndex: 0,
isConfigPanelOpen: false,
},
meta: {
dateTime: { start_time: new Date(1000), end_time: new Date(2000) },
stream: { customQueryFields: [], vrlFunctionFieldList: [] },
streamFields: { groupedFields: [] },
},
});
vi.mock("vuex", async (importOriginal) => ({
...(await importOriginal<typeof import("vuex")>()),
useStore: () => ({
state: {
timezone: "UTC",
theme: "light",
selectedOrganization: { identifier: "org1" },
organizationData: { isDataIngested: true },
zoConfig: {
query_history_enabled: true,
timestamp_column: "_timestamp",
default_functions: [],
auto_query_enabled: false,
},
savedViewDialog: { show: false },
},
commit: vi.fn(),
dispatch: vi.fn().mockResolvedValue({}),
getters: {},
}),
}));
vi.mock("@/composables/dashboard/useDashboardPanel", () => ({
default: () => ({
dashboardPanelData: panel.current,
resetDashboardPanelData: vi.fn(),
validatePanel: vi.fn(),
makeAutoSQLQuery: vi.fn(async () => undefined),
regenerateOtherBuilderQueries: vi.fn(),
resetAggregationFunction: vi.fn(),
updateGroupedFields: vi.fn(),
}),
}));
vi.mock("@/components/dashboards/PanelEditor/composables/usePanelEditor", () => ({
usePanelEditor: vi.fn(() => {
editor.isLoading = ref(false);
editor.chartData = ref({});
return {
chartData: editor.chartData,
errorData: reactive({ errors: [] }),
metaData: ref(null),
seriesData: ref([]),
lastTriggeredAt: ref(null),
showLegendsDialog: ref(false),
shouldRefreshWithoutCache: ref(false),
maxQueryRangeWarning: ref(""),
panelDiagnostics: ref(null),
seriesLimit: ref(null),
noticeProps: ref({}),
handlePanelDiagnosticsUpdate: vi.fn(),
handleSeriesLimitUpdate: vi.fn(),
limitNumberOfSeriesWarningMessage: ref(""),
errorMessage: ref(""),
isPartialData: ref(false),
isPanelLoading: ref(false),
isCachedDataDifferWithCurrentTimeRange: ref(false),
searchRequestTraceIds: ref([]),
panelSchemaRendererRef: ref(null),
splitterModel: ref(50),
isOutDated: ref(false),
isLoading: editor.isLoading,
currentPanelData: ref({}),
initChartData: vi.fn(),
runQuery: vi.fn(() => {
if (editor.isLoading) editor.isLoading.value = true;
}),
handleChartApiError: vi.fn(),
handleLastTriggeredAtUpdate: vi.fn(),
handleLimitNumberOfSeriesWarningMessage: vi.fn(),
handleIsPartialDataUpdate: vi.fn(),
handleLoadingStateChange: vi.fn(),
handleIsCachedDataDifferWithCurrentTimeRangeUpdate: vi.fn(),
handleResultMetadataUpdate: vi.fn(),
metaDataValue: vi.fn(),
seriesDataUpdate: vi.fn(),
collapseFieldList: vi.fn(),
layoutSplitterUpdated: vi.fn(),
updateVrlFunctionFieldList: vi.fn(),
onDataZoom: vi.fn().mockReturnValue({ start: new Date(), end: new Date() }),
cancelRunningQuery: vi.fn(),
resetErrors: vi.fn(),
updateDateTime: vi.fn(),
};
}),
}));
const historyApi = vi.hoisted(() => ({
record: vi.fn(),
list: vi.fn(),
star: vi.fn(),
remove: vi.fn(),
}));
vi.mock("@/services/query_history", () => ({ default: historyApi }));
vi.mock("@/utils/streamPersist", () => ({ restoreMetricsStream: vi.fn(() => "") }));
import MetricsVisualize from "./MetricsVisualize.vue";
enableAutoUnmount(afterEach);
const RANGE = { valueType: "relative", relativeTimePeriod: "1h", startTime: null, endTime: null };
const mountVisualize = () =>
shallowMount(MetricsVisualize, {
props: { selectedDateTime: { startTime: 1000, endTime: 2000 } },
global: {
plugins: [i18n],
stubs: {
PanelEditor: false,
OSplitter: {
name: "OSplitter",
template:
'<div><slot name="before" /><slot name="separator" /><slot name="after" /></div>',
},
PanelSchemaRenderer: {
name: "PanelSchemaRenderer",
emits: ["error"],
template: "<div />",
},
},
},
});
const settle = async (wrapper: ReturnType<typeof mountVisualize>, error: object) => {
wrapper.findComponent({ name: "PanelSchemaRenderer" }).vm.$emit("error", error);
if (editor.isLoading) editor.isLoading.value = false;
await flushPromises();
await nextTick();
};
const run = async (wrapper: ReturnType<typeof mountVisualize>) => {
(wrapper.vm as unknown as VisualizeApi).onUserRun(RANGE);
await flushPromises();
};
describe("Metrics history status from the real PanelEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
panel.current = makePanel();
historyApi.record.mockResolvedValue({ data: {} });
});
it("renders the real PanelEditor around the renderer", async () => {
const wrapper = mountVisualize();
await flushPromises();
expect(wrapper.findComponent({ name: "PanelSchemaRenderer" }).exists()).toBe(true);
});
it("records a renderer query error as failed with its message", async () => {
const wrapper = mountVisualize();
await flushPromises();
if (panel.current) panel.current.data.queries[0].query = "sum(up{";
await run(wrapper);
await settle(wrapper, { message: "parse error: unclosed left brace", code: 400 });
expect(historyApi.record).toHaveBeenCalledTimes(1);
expect(historyApi.record.mock.calls[0][1].context.time).toMatchObject({
start_time: 1000,
end_time: 2000,
});
expect(historyApi.record.mock.calls[0][1]).toMatchObject({
source: "metrics",
query: "sum(up{",
status: "error",
context: { error: "parse error: unclosed left brace" },
});
});
it("records ok once the renderer clears its error, and nothing for a cancel", async () => {
const wrapper = mountVisualize();
await flushPromises();
await run(wrapper);
await settle(wrapper, { message: "query cancelled", code: 20009 });
expect(historyApi.record).not.toHaveBeenCalled();
await run(wrapper);
await settle(wrapper, { message: "", code: "" });
expect(historyApi.record).toHaveBeenCalledTimes(1);
expect(historyApi.record.mock.calls[0][1]).toMatchObject({ status: "ok" });
expect(historyApi.record.mock.calls[0][1].context.error).toBeUndefined();
});
it("records a timeout as ok with a warning", async () => {
const wrapper = mountVisualize();
await flushPromises();
await run(wrapper);
await settle(wrapper, { message: "search timed out", code: 20010 });
expect(historyApi.record.mock.calls[0][1]).toMatchObject({
status: "ok",
context: { warning: "search timed out" },
});
});
});
@@ -15,7 +15,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { reactive } from "vue";
import { nextTick, reactive, type SetupContext } from "vue";
// A fresh panel-data object per test so mounting can reset it in place and the
// assertions see the metrics defaults the container applied.
@@ -49,6 +49,7 @@ const editorRunQuery = vi.hoisted(() => vi.fn());
const injectedRunQuery = vi.hoisted(() => ({ current: null as any }));
/** The stub's exposed staleness state, as refs so the container's computed tracks them. */
const editorState = vi.hoisted(() => ({ current: null as any }));
const emitChartError = vi.hoisted(() => ({ current: null as ((e: unknown) => void) | null }));
// The real PanelEditor pulls in ECharts + the whole dashboard config surface;
// none of that is what this container's own logic needs. Stub it, but keep its
@@ -61,9 +62,10 @@ vi.mock("@/components/dashboards/PanelEditor", async () => {
PanelEditor: {
name: "PanelEditor",
props: ["allowedChartTypes"],
emits: ["add-to-dashboard", "chart-api-error"],
setup: (_: any, { expose }: any) => {
emits: ["add-to-dashboard", "chart-api-error", "renderer-error"],
setup: (_: unknown, { expose, emit }: SetupContext) => {
injectedRunQuery.current = inject("runQuery", null);
emitChartError.current = (e: unknown) => emit("renderer-error", e);
editorState.current = {
isOutDated: ref(false),
isLoading: ref(false),
@@ -72,6 +74,7 @@ vi.mock("@/components/dashboards/PanelEditor", async () => {
// Like the real editor, a run snapshots the panel it ran with.
const runQuery = (...args: any[]) => {
editorRunQuery(...args);
editorState.current.isLoading.value = true;
editorState.current.chartData.value = JSON.parse(JSON.stringify(panelData.current.data));
};
expose({ runQuery, ...editorState.current });
@@ -103,7 +106,7 @@ vi.mock("@/utils/streamPersist", () => ({
// partial: the panel seed path reaches @/stores, which needs the real createStore
const storeState = vi.hoisted(() => ({
selectedOrganization: { identifier: "org1" },
zoConfig: { auto_query_enabled: false },
zoConfig: { auto_query_enabled: false, query_history_enabled: true },
}));
vi.mock("vuex", async (importOriginal) => ({
...(await importOriginal<typeof import("vuex")>()),
@@ -119,6 +122,17 @@ const historyApi = vi.hoisted(() => ({
vi.mock("@/services/query_history", () => ({ default: historyApi }));
import MetricsVisualize from "./MetricsVisualize.vue";
interface VisualizeApi {
onUserRun: (range: object) => void;
}
const finishRun = async (error?: { message: string; code: number }) => {
if (error) emitChartError.current?.(error);
editorState.current.isLoading.value = false;
await flushPromises();
await nextTick();
};
import { encodeMetricsConfig } from "@/composables/metrics/metricsUrlState";
import { LIVE_PREVIEW_DEBOUNCE_MS } from "@/composables/dashboard/usePanelLivePreview";
@@ -203,6 +217,7 @@ describe("MetricsVisualize", () => {
panelData.current.data.queries[0].query = "avg(up)";
(wrapper.vm as any).runQuery();
await flushPromises();
await finishRun();
editorRunQuery.mockClear();
window.dispatchEvent(new Event("pointerdown"));
return wrapper;
@@ -224,6 +239,7 @@ describe("MetricsVisualize", () => {
vm.onUserRun({ valueType: "relative", relativeTimePeriod: "15m" });
await flushPromises();
await finishRun();
expect(vm.isRunQueryPending).toBe(false);
expect(historyApi.record).toHaveBeenCalledTimes(1);
});
@@ -265,6 +281,7 @@ describe("MetricsVisualize", () => {
const wrapper = await mountEdited();
(wrapper.vm as any).onUserRun({ valueType: "relative", relativeTimePeriod: "15m" }, true);
await flushPromises();
await finishRun();
expect(editorRunQuery).toHaveBeenCalledWith(true);
expect(historyApi.record).toHaveBeenCalledTimes(1);
});
@@ -360,6 +377,8 @@ describe("MetricsVisualize", () => {
(wrapper.vm as any).onUserRun(RANGE);
await flushPromises();
expect(historyApi.record).not.toHaveBeenCalled();
await finishRun();
expect(editorRunQuery).toHaveBeenCalledTimes(1);
expect(historyApi.record).toHaveBeenCalledWith(
@@ -403,6 +422,7 @@ describe("MetricsVisualize", () => {
(wrapper.vm as any).onUserRun(RANGE);
await flushPromises();
await finishRun();
expect(editorRunQuery).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalled();
expect(showErrorNotification).not.toHaveBeenCalled();
@@ -423,6 +443,26 @@ describe("MetricsVisualize", () => {
expect(historyApi.record).not.toHaveBeenCalled();
});
it("records a failed run as error and a cancelled run not at all", async () => {
const wrapper = mountVisualize({ selectedDateTime: { startTime: 1000, endTime: 2000 } });
await flushPromises();
withQuery("sum(up{");
(wrapper.vm as unknown as VisualizeApi).onUserRun(RANGE);
await flushPromises();
await finishRun({ message: "parse error: unclosed left brace", code: 400 });
expect(historyApi.record.mock.calls[0][1]).toMatchObject({
status: "error",
context: { error: "parse error: unclosed left brace" },
});
historyApi.record.mockClear();
(wrapper.vm as unknown as VisualizeApi).onUserRun(RANGE);
await flushPromises();
await finishRun({ message: "cancelled", code: 20009 });
expect(historyApi.record).not.toHaveBeenCalled();
});
it("applies two consecutive entries live, each with its range, running once each", async () => {
// A parent that owns the range, as the explorer does.
const Parent = {
@@ -460,7 +500,13 @@ describe("MetricsVisualize", () => {
expect(data.queries[0].query).toBe("max(b)");
expect(panelData.current.meta.dateTime.start_time).toEqual(new Date(7000));
expect(editorRunQuery).toHaveBeenCalledTimes(2);
expect(historyApi.record).not.toHaveBeenCalled();
await finishRun();
expect(historyApi.record).toHaveBeenCalledTimes(1);
await viz.applyPanelData(blob("bar", "sum(c)"), RANGE, false);
await flushPromises();
expect(data.queries[0].query).toBe("sum(c)");
expect(editorRunQuery).toHaveBeenCalledTimes(2);
});
});
});
@@ -39,6 +39,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:selected-date-time="dashboardPanelData.meta.dateTime"
@add-to-dashboard="onAddToDashboard"
@chart-api-error="onChartApiError"
@renderer-error="onRendererError"
/>
<AddToDashboard
@@ -69,6 +70,7 @@ import useNotifications from "@/composables/useNotifications";
import { restoreMetricsStream } from "@/utils/streamPersist";
import { applyMetricsBlob, normaliseMetricsBlobData } from "@/composables/metrics/metricsUrlState";
import { useQueryHistoryRecorder } from "@/composables/metrics/useQueryHistoryRecorder";
import type { PanelErrorDetail } from "@/utils/queryHistory/metrics";
import type { SelectedDate } from "@/utils/dashboard/urlTimeParams";
import { PanelEditor } from "@/components/dashboards/PanelEditor";
import type { PanelEditorVariablesData } from "@/components/dashboards/PanelEditor";
@@ -195,6 +197,11 @@ export default defineComponent({
// PanelEditor surfaces its own error UI; nothing extra to do here.
};
// History reads the renderer's error to classify the run; PanelEditor shows it itself.
const onRendererError = (error: PanelErrorDetail | string | null) => {
historyRecorder.onPanelError(error);
};
const timeKey = () => `${props.selectedDateTime?.startTime}:${props.selectedDateTime?.endTime}`;
// The window the chart last ran with; null until the first one is applied, which always applies.
const appliedTimeKey = ref<string | null>(null);
@@ -270,27 +277,27 @@ export default defineComponent({
if (on && isTimeChangePending.value) runQuery();
});
const { record } = useQueryHistoryRecorder();
const historyRecorder = useQueryHistoryRecorder(() => !!panelEditorRef.value?.isLoading);
/** An explicit Run (button, shortcut or Refresh Cache & Run): the only run that writes history. */
const onUserRun = (selectedDate: SelectedDate, withoutCache = false) => {
runQuery(withoutCache);
const errors: string[] = [];
validatePanel(errors, false);
if (!errors.length) record(dashboardPanelData, selectedDate);
if (!errors.length) historyRecorder.record(dashboardPanelData, selectedDate);
};
// Set while applying an entry, so its range change does not also run the old query.
let applyingEntry = false;
/** Loads a history entry live: its panel in place, its range on the parent's picker, one run. */
const applyPanelData = async (metricsData: string, timeRange: SelectedDate) => {
/** Loads a history entry live: its panel in place, its range on the parent's picker; a re-run is a user Run. */
const applyPanelData = async (metricsData: string, timeRange: SelectedDate, run = true) => {
if (!applyMetricsBlob(metricsData, dashboardPanelData)) return;
applyingEntry = true;
emit("update:time-range", timeRange);
await nextTick();
applyingEntry = false;
runQuery();
if (run) onUserRun(timeRange);
};
// Declared AFTER applyDateTime/runQuery: the hooks call them, and a const
@@ -332,6 +339,7 @@ export default defineComponent({
showAddToDashboardDialog,
onAddToDashboard,
onChartApiError,
onRendererError,
runQuery,
onUserRun,
applyPanelData,
+141
View File
@@ -26,6 +26,10 @@ import router from "@/test/unit/helpers/router";
import * as useDurationPercentilesModule from "@/composables/useDurationPercentiles";
import { buildViewTracesFilter } from "@/plugins/traces/viewTracesHandoff";
import analytics from "@/services/product_analytics";
import queryHistory from "@/services/query_history";
import type { QueryHistoryEntry } from "@/services/query_history";
import { openRecentOnItsPage, takeRecentLanding } from "@/utils/queryHistory/routes";
import { landingRouter, landWithoutRestore } from "@/test/unit/helpers/recentLanding";
import {
announceTracesRowNav,
tracesRowNavAnnouncement,
@@ -1609,6 +1613,143 @@ describe("Index.vue (Main Traces Page)", () => {
return calls[calls.length - 1][1];
};
it("records a Run in recent searches once it completes, and never a page fetch", async () => {
const record = vi.spyOn(queryHistory, "record").mockResolvedValue({ data: {} } as never);
await mountPage();
mockSearchObj.data.resultGrid.currentPage = 0;
mockSearchObj.data.editorValue = "service_name = 'cart'";
wrapper.vm.runQueryFn();
await flushPromises();
lastCallbacks().complete({});
await flushPromises();
expect(record).toHaveBeenCalledTimes(1);
expect(record.mock.calls[0][1]).toMatchObject({
source: "traces",
query: "service_name = 'cart'",
streams: ["default"],
status: "ok",
context: { page: { stream: "default", tab: "spans" } },
});
record.mockClear();
await wrapper.vm.getMoreData();
lastCallbacks().complete({});
await flushPromises();
expect(record).not.toHaveBeenCalled();
record.mockRestore();
});
it("records a query error as failed and a cancel not at all", async () => {
const record = vi.spyOn(queryHistory, "record").mockResolvedValue({ data: {} } as never);
await mountPage();
mockSearchObj.data.editorValue = "nosuch = 1";
wrapper.vm.runQueryFn();
await flushPromises();
lastCallbacks().error({}, { content: { code: 20004, message: "field not found" } });
await flushPromises();
expect(record.mock.calls[0][1]).toMatchObject({
status: "error",
context: { error: "field not found" },
});
record.mockClear();
wrapper.vm.runQueryFn();
await flushPromises();
lastCallbacks().error({}, { content: { code: 20009, message: "cancelled" } });
await flushPromises();
expect(record).not.toHaveBeenCalled();
record.mockRestore();
});
describe("page landings and restores from another page", () => {
const restoredEntry: QueryHistoryEntry = {
id: "t1",
source: "traces",
query: "service_name = 'cart'",
query_key: "k",
streams: ["default"],
context: { page: { stream: "default", tab: "spans" } },
status: "ok",
starred: false,
run_count: 1,
created_at: 1,
updated_at: 2,
};
const landWith = async (run: boolean | null) => {
mockSearchObj.data.stream.streamLists = [{ label: "default", value: "default" }];
mockSearchObj.data.stream.selectedStream = { label: "default", value: "default" };
if (run !== null) {
await openRecentOnItsPage(landingRouter().router, restoredEntry, "now", "default", run);
}
mockSearchObj.data.editorValue = "service_name = 'cart'";
const before = mockFetchQueryDataWithHttpStream.mock.calls.length;
await wrapper.vm.loadPageData();
await flushPromises();
mockSearchObj.data.stream.streamLists = [{ label: "default", value: "default" }];
mockSearchObj.data.stream.selectedStream = { label: "default", value: "default" };
return mockFetchQueryDataWithHttpStream.mock.calls.length - before;
};
it("runs a plain landing without recording it", async () => {
const record = vi.spyOn(queryHistory, "record").mockResolvedValue({ data: {} } as never);
await mountPage();
record.mockClear();
expect(await landWith(null)).toBe(1);
lastCallbacks().complete({});
await flushPromises();
expect(record).not.toHaveBeenCalled();
record.mockRestore();
});
it("runs and records a restore that asked to run", async () => {
const record = vi.spyOn(queryHistory, "record").mockResolvedValue({ data: {} } as never);
await mountPage();
record.mockClear();
expect(await landWith(true)).toBe(1);
lastCallbacks().complete({});
await flushPromises();
expect(record).toHaveBeenCalledTimes(1);
record.mockRestore();
});
it("claims a restore that finds no stream, so a later landing is not taken for it", async () => {
await mountPage();
await openRecentOnItsPage(landingRouter().router, restoredEntry, "now", "default", true);
mockGetStreams.mockResolvedValueOnce({ list: [] });
mockSearchObj.data.stream.streamLists = [];
mockSearchObj.data.stream.selectedStream = { label: "", value: "" };
const before = mockFetchQueryDataWithHttpStream.mock.calls.length;
await wrapper.vm.loadPageData();
await flushPromises();
expect(mockSearchObj.data.stream.selectedStream.value || "").toBe("");
expect(mockFetchQueryDataWithHttpStream.mock.calls.length).toBe(before);
expect(takeRecentLanding("traces")).toBeNull();
landWithoutRestore();
});
it("remembers the query each search ran with, so the Run button can tell when the editor moved on", async () => {
await mountPage();
mockSearchObj.data.editorValue = "service_name = 'cart'";
wrapper.vm.runQueryFn();
await flushPromises();
expect(mockSearchObj.data.appliedEditorValue).toBe("service_name = 'cart'");
mockSearchObj.data.editorValue = "service_name = 'checkout'";
await wrapper.vm.getMoreData();
await flushPromises();
expect(mockSearchObj.data.appliedEditorValue).toBe("service_name = 'cart'");
});
it("only loads a restore that asked not to run", async () => {
const record = vi.spyOn(queryHistory, "record").mockResolvedValue({ data: {} } as never);
await mountPage();
record.mockClear();
expect(await landWith(false)).toBe(0);
await flushPromises();
expect(record).not.toHaveBeenCalled();
record.mockRestore();
});
});
describe("J/K page crossing signals (4a §3.4)", () => {
const navigation = () => mockSearchObj.meta.resultGrid.navigation;
const pend = () => {
+94 -12
View File
@@ -68,7 +68,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:isLoading="searchObj.loading"
:activeTab="activeTab"
class="bg-card-glass-bg"
@searchdata="searchData"
@searchdata="searchData()"
@onChangeTimezone="refreshTimezone"
@update:activeTab="activeTab = $event"
@error-only-toggled="onErrorOnlyToggled"
@@ -76,6 +76,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@cancel-query="cancelSearch"
@update:searchMode="onSearchModeChange"
@apply-saved-view="onApplySavedView"
@recent-restore="onRecentRestore"
@service-graph-refresh="serviceGraphRef?.refresh()"
@services-catalog-refresh="servicesCatalogRef?.loadServicesCatalog()"
@drill-down="searchResultRef?.openComparison()"
@@ -228,7 +229,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@shareLink="(range: any) => copyTracesUrl(t, range)"
@metrics:editor-filter-set="onMetricsEditorFilterSet"
@metrics:editor-filter-run="onMetricsEditorFilterRun"
@run-query="searchData"
@run-query="searchData()"
@remove-filter="onRemoveTracesFilter"
@jump-to-stream-data="onJumpToTracesStreamData"
@error-only-toggled="onErrorOnlyToggled"
@@ -359,6 +360,15 @@ import { toast } from "@/lib/feedback/Toast/useToast";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import { recordPageLoad, recordPageRequest, type PageLoadReason } from "@/utils/pageCrossing";
import { isInputFocused } from "@/utils/keyboardShortcuts";
import { useRecentSearches } from "@/composables/queryHistory/useRecentSearches";
import {
runResultFromError,
toRunError,
type RestoreTime,
} from "@/utils/queryHistory/recentSearches";
import { buildTracesRecord, tracesRestore } from "@/utils/queryHistory/traces";
import { openRecentOnItsPage, takeRecentLanding } from "@/utils/queryHistory/routes";
import type { HistoryTime, QueryHistoryEntry } from "@/services/query_history";
interface TracesSavedView {
version?: number;
@@ -381,6 +391,7 @@ const supportedTraceSearchMode = (value: unknown): TraceSearchMode =>
resolveTraceSearchMode(value, config.isEnterprise === "true");
const store = useStore();
const { record: recordRecent } = useRecentSearches();
const activeTab = computed(() => {
if (searchObj.meta.searchMode === "service-graph") return "service-graph";
if (searchObj.meta.searchMode === "services-catalog") return "services-catalog";
@@ -835,7 +846,11 @@ const dropStaleSelection = () => {
if (dropped) searchResultRef.value?.metricsDashboardRef?.clearOriginalTimeRange();
};
async function getQueryData(isPagination: boolean = false, isSort: boolean = false) {
async function getQueryData(
isPagination: boolean = false,
isSort: boolean = false,
recordRun: boolean = false,
) {
let boundTraceId: string | null = null;
try {
if (searchObj.data.stream.selectedStream.value == "") {
@@ -888,9 +903,11 @@ async function getQueryData(isPagination: boolean = false, isSort: boolean = fal
// A search or sort reads the editor and the resolved window; a page fetch keeps page 1's, so its selection still holds.
if (!isPagination) {
submittedFilter = buildEditorFilter();
searchObj.data.appliedEditorValue = String(searchObj.data.editorValue ?? "");
dropStaleSelection();
}
const combinedFilter = submittedFilter;
const historyRun = recordRun && !isPagination ? tracesHistoryRun(queryReq) : null;
if (!isPagination && !isSort) searchResultRef?.value?.getDashboardData();
@@ -1054,6 +1071,12 @@ async function getQueryData(isPagination: boolean = false, isSort: boolean = fal
const errData = err?.content || err;
const { message, trace_id, code, error_detail } = errData ?? {};
if (historyRun) {
const result = runResultFromError(
toRunError({ code, status: errData?.status, message }),
);
if (result) recordRecent(buildTracesRecord({ ...historyRun, result }));
}
let errorMsg = message || err?.message || t("traces.index.errorProcessingRequest");
if (code) {
@@ -1088,6 +1111,9 @@ async function getQueryData(isPagination: boolean = false, isSort: boolean = fal
delete tracesRequestState[searchTraceId];
},
complete: (_payload: any) => {
if (historyRun && tracesRequestState[searchTraceId]) {
recordRecent(buildTracesRecord({ ...historyRun, result: { status: "ok" } }));
}
searchObj.loading = false;
currentSearchTraceId = null;
// An exhausted page must not leave the previous page under the new page number.
@@ -1378,6 +1404,8 @@ const isAutoRunOff = () => !!store.state.zoConfig?.auto_query_enabled && !search
async function loadPageData() {
const streamFromUrl = typeof router.currentRoute.value.query.stream === "string";
// Claimed before any early return, so a restore that finds no stream is not left for a later landing.
const landing = takeRecentLanding("traces");
searchObj.loadingStream = true;
if (!searchObj.data?.queryResults?.hits?.length) searchObj.data.resultGrid.currentPage = 0;
@@ -1388,9 +1416,13 @@ async function loadPageData() {
//get stream list
await getStreamList();
if (searchObj.data.stream.selectedStream.value && (streamFromUrl || !isAutoRunOff())) {
searchData();
if (!searchObj.data.stream.selectedStream.value) return;
// Only a restore from recent searches is a user run; any other landing runs without recording.
if (landing) {
if (landing.run) searchData();
return;
}
if (streamFromUrl || !isAutoRunOff()) searchData(false);
}
onBeforeMount(async () => {
@@ -1448,17 +1480,34 @@ onActivated(async () => {
if (applyHandoffFilter()) {
await loadPageData();
await nextTick();
runQueryFn();
runQueryFn(false);
}
}
});
const runQueryFn = () => {
/** Runs the search; `record` is false for runs no user asked for, such as a page landing. */
const runQueryFn = (record = true) => {
searchObj.data.resultGrid.currentPage = 0;
searchObj.runQuery = false;
getQueryData();
getQueryData(false, false, record);
};
/** What a run records once it settles, captured when it is dispatched. */
function tracesHistoryRun(queryReq: { query: { start_time: number; end_time: number } }) {
const datetime = searchObj.data.datetime;
return {
stream: String(selectedStreamName.value ?? ""),
searchMode: String(searchObj.meta.searchMode),
query: String(searchObj.data.editorValue ?? ""),
time: {
type: datetime.type === "absolute" ? "absolute" : "relative",
...(datetime.type === "absolute" ? {} : { period: String(datetime.relativeTimePeriod) }),
start_time: Number(queryReq.query.start_time),
end_time: Number(queryReq.query.end_time),
} as HistoryTime,
};
}
const runQueryOnSort = () => {
searchObj.data.resultGrid.currentPage = 0;
searchObj.runQuery = false;
@@ -1927,14 +1976,14 @@ const showErrorOnly = computed(
() => activeIncludeFilterValues.value["span_status"]?.includes("ERROR") ?? false,
);
const searchData = () => {
const searchData = (record = true) => {
if (!(searchObj.data.stream.streamLists.length && searchObj.data.stream.selectedStream?.label)) {
return;
}
if (activeTab.value === "service-graph" || activeTab.value === "services-catalog") return;
runQueryFn();
runQueryFn(record);
analytics.track("traces_search_run", {
stream_name: searchObj.data.stream.selectedStream.value,
@@ -1975,11 +2024,16 @@ const syncSavedViewDateTime = async (datetime: TracesSavedView["datetime"]) => {
let savedViewApplySeq = 0;
const onApplySavedView = async (view: TracesSavedView) => {
const seq = ++savedViewApplySeq;
if (view.version !== undefined && view.version !== 1) {
toast({ variant: "error", message: t("search.errorWhileApplyingSavedView") });
return;
}
await applyTracesView(view, true);
};
/** Applies a stream, filter, window and mode in place, then runs unless only loading. */
const applyTracesView = async (view: TracesSavedView, run: boolean) => {
const seq = ++savedViewApplySeq;
const streamName = view.stream?.value;
const streamChanged = streamName !== searchObj.data.stream.selectedStream.value;
if (
@@ -2023,7 +2077,35 @@ const onApplySavedView = async (view: TracesSavedView) => {
if (view.editorValue && searchObj.data.stream.selectedStreamFields.length) {
restoreFilters(view.editorValue);
}
runQueryFn();
if (run) runQueryFn();
};
const onRecentRestore = (entry: QueryHistoryEntry, when: RestoreTime, run: boolean) => {
if (entry.source !== "traces") {
void openRecentOnItsPage(router, entry, when, store.state.selectedOrganization.identifier, run);
return;
}
const restore = tracesRestore(entry, when);
const current = searchObj.data.datetime;
const window = restore.window;
void applyTracesView(
{
stream: { label: restore.stream, value: restore.stream },
editorValue: restore.query,
datetime: {
type: window?.type ?? current.type,
relativeTimePeriod:
window?.type === "relative" ? window.period : current.relativeTimePeriod,
startTime: window?.type === "absolute" ? window.startUs : current.startTime,
endTime: window?.type === "absolute" ? window.endUs : current.endTime,
},
searchMode: restore.searchMode === "spans" ? "spans" : "traces",
sortBy: searchObj.meta.resultGrid.sortBy,
sortOrder: searchObj.meta.resultGrid.sortOrder,
selectedFields: [...(searchObj.data.stream.selectedFields ?? [])],
},
run,
);
};
const streamChangeDialog = ref({ show: false, pendingStream: "" });
+118 -3
View File
@@ -305,6 +305,7 @@ import SearchBar from "@/plugins/traces/SearchBar.vue";
import analytics from "@/services/product_analytics";
import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue";
import { useToolbarPins } from "@/composables/useToolbarPins";
import { placeCaret } from "@/test/unit/helpers/monacoCaret";
// ---------------------------------------------------------------------------
// DOM anchor node required by attachTo
@@ -339,7 +340,12 @@ const sharedStubs = {
},
},
CodeQueryEditor: {
template: '<div data-test="code-query-editor-stub" />',
// Monaco's caret element: the recent-searches dropdown opens at it.
template:
'<div data-test="code-query-editor-stub"><div class="monaco-editor"><div class="cursors-layer"><div class="cursor" /></div></div></div>',
mounted(this: { $el: Element }) {
placeCaret(this.$el.querySelector(".cursor"));
},
props: [
"editorId",
"query",
@@ -351,12 +357,14 @@ const sharedStubs = {
"debounceTime",
"fieldValueResolver",
],
emits: ["update:query", "run-query", "focus", "blur"],
setup() {
emits: ["update:query", "run-query", "focus", "blur", "user-edit"],
setup(_: unknown, { emit }: { emit: (event: "focus") => void }) {
return {
setValue: vi.fn(),
getCursorIndex: vi.fn().mockReturnValue(0),
triggerAutoComplete: vi.fn(),
// Monaco fires its focus event inside focus(), in the same tick.
focus: () => emit("focus"),
};
},
},
@@ -2431,4 +2439,111 @@ describe("SearchBar", () => {
expect(searchObjInstance.data.parsedQuery).toEqual(previous);
});
});
describe("recent-searches dropdown and the editor placeholder", () => {
it("hides the typing placeholder while the dropdown is open, so only one list changes", async () => {
wrapper = mountSearchBar();
await flushPromises();
searchObjInstance.data.editorValue = "";
searchObjInstance.meta.queryEditorPlaceholderFlag = true;
const vm = wrapper.vm as unknown as { recentFocusOpen: boolean };
vm.recentFocusOpen = false;
await flushPromises();
expect(wrapper.find(".query-editor-placeholder-overlay").exists()).toBe(true);
vm.recentFocusOpen = true;
await flushPromises();
expect(wrapper.find(".query-editor-placeholder-overlay").exists()).toBe(false);
vm.recentFocusOpen = false;
await flushPromises();
expect(wrapper.find(".query-editor-placeholder-overlay").exists()).toBe(true);
});
});
describe("the Run button once the editor differs from the searched query", () => {
const runButton = () => wrapper.find('[data-test="logs-search-bar-refresh-btn"]');
it("shows the pending state for a typed edit or a loaded recent search, and clears it when they match again", async () => {
wrapper = mountSearchBar();
await flushPromises();
searchObjInstance.data.appliedEditorValue = "service_name='a'";
searchObjInstance.data.editorValue = "service_name='a'";
await flushPromises();
expect(runButton().attributes("data-run-pending")).toBeUndefined();
searchObjInstance.data.editorValue = "service_name='b'";
await flushPromises();
expect(runButton().attributes("data-run-pending")).toBe("true");
expect(runButton().attributes("aria-label")).toBe("search.runQueryPendingLabel");
searchObjInstance.data.editorValue = "service_name='a' ";
await flushPromises();
expect(runButton().attributes("data-run-pending")).toBeUndefined();
});
it("shows no pending state before the first search", async () => {
wrapper = mountSearchBar();
await flushPromises();
searchObjInstance.data.appliedEditorValue = null;
searchObjInstance.data.editorValue = "service_name='b'";
await flushPromises();
expect(runButton().attributes("data-run-pending")).toBeUndefined();
});
});
describe("recent-searches dropdown on an edit that empties the editor", () => {
it("opens without a refocus when the text is cleared and closes on the next character", async () => {
store.state.zoConfig.query_history_enabled = true;
wrapper = mountSearchBar();
await flushPromises();
searchObjInstance.data.editorValue = "service_name='api'";
const vm = wrapper.vm as unknown as { recentFocusOpen: boolean };
const editor = wrapper.findComponent('[data-test="code-query-editor-stub"]');
editor.vm.$emit("focus");
await flushPromises();
expect(vm.recentFocusOpen).toBe(false);
editor.vm.$emit("user-edit", true, false);
await flushPromises();
expect(vm.recentFocusOpen).toBe(true);
editor.vm.$emit("user-edit", false, true);
await flushPromises();
expect(vm.recentFocusOpen).toBe(false);
searchObjInstance.data.editorValue = "";
editor.vm.$emit("focus");
await flushPromises();
expect(vm.recentFocusOpen).toBe(true);
vm.recentFocusOpen = false;
editor.vm.$emit("user-edit", true, false);
await flushPromises();
expect(vm.recentFocusOpen).toBe(true);
delete store.state.zoConfig.query_history_enabled;
});
it("stays closed when Escape in the list hands focus back to the editor, until the next real focus", async () => {
store.state.zoConfig.query_history_enabled = true;
wrapper = mountSearchBar();
await flushPromises();
searchObjInstance.data.editorValue = "";
const vm = wrapper.vm as unknown as { recentFocusOpen: boolean };
const editor = wrapper.findComponent('[data-test="code-query-editor-stub"]');
editor.vm.$emit("focus");
await flushPromises();
expect(vm.recentFocusOpen).toBe(true);
const dropdown = wrapper.findComponent({ name: "RecentSearchesFocusDropdown" });
dropdown.vm.$emit("update:open", false);
dropdown.vm.$emit("dismiss");
await flushPromises();
expect(vm.recentFocusOpen).toBe(false);
editor.vm.$emit("focus");
await flushPromises();
expect(vm.recentFocusOpen).toBe(true);
delete store.state.zoConfig.query_history_enabled;
});
});
});
+150 -9
View File
@@ -321,6 +321,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@on:date-change="updateDateTime"
@on:timezone-change="updateTimezone"
/>
<RecentSearchesButton
source="traces"
:examples="recentExamples"
data-test="traces-search-bar-recent-searches-btn"
@run="(entry, when) => emit('recent-restore', entry, when, true)"
@load="(entry, when) => emit('recent-restore', entry, when, false)"
@view-all="openRecentSearches"
@example="applyRecentExample"
/>
<div>
<div class="flex items-center">
<OButton
@@ -334,23 +343,33 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<OButton
v-else
variant="ghost"
:variant="showRunQueryPending ? 'pending' : 'ghost'"
data-test="logs-search-bar-refresh-btn"
data-cy="search-bar-refresh-button"
:data-run-pending="showRunQueryPending ? 'true' : undefined"
:aria-label="showRunQueryPending ? t('search.runQueryPendingLabel') : undefined"
:title="t('search.runQuery')"
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="
class="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]"
:class="[
store.state.zoConfig.auto_query_enabled
? '![border-radius:0.375rem_0_0_0.375rem]'
: 'rounded-default'
"
: 'rounded-default',
showRunQueryPending
? ''
: 'bg-button-primary! text-button-primary-foreground! hover:ring-button-primary/70 hover:opacity-90 hover:ring-2',
]"
@click="searchData"
:loading="isLoading"
:disabled="isLoading"
>
<OTooltip
v-if="searchObj.meta.liveMode && store.state.zoConfig.auto_query_enabled"
:content="t('search.autoRunEnabled')"
v-if="
(searchObj.meta.liveMode && store.state.zoConfig.auto_query_enabled) ||
showRunQueryPending
"
:content="
showRunQueryPending ? t('search.autoRunPendingDot') : t('search.autoRunEnabled')
"
/>
<OIcon
v-if="searchObj.meta.liveMode && store.state.zoConfig.auto_query_enabled"
@@ -509,7 +528,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"
class="border-border-default flex min-h-0 flex-1 border-b"
>
<div class="relative flex h-full w-full flex-col overflow-hidden">
<div
ref="queryEditorBoxRef"
class="relative flex h-full w-full flex-col overflow-hidden"
@keydown.capture="onRecentEditorKeydown"
>
<CodeQueryEditor
ref="queryEditorRef"
editor-id="traces-query-editor"
@@ -517,22 +540,45 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:keywords="effectiveKeywords"
:suggestions="effectiveSuggestions"
:field-value-resolver="resolveFieldValues"
:recent-queries="recentQueries"
:debounce-time="100"
:filter-mode="!searchObj.meta.sqlMode"
language="sql"
@update:query="updateQueryValue"
@user-edit="onRecentEditorEdit"
@run-query="searchData"
@focus="onQueryEditorFocus"
@blur="onQueryEditorBlur"
/>
<RecentSearchesFocusDropdown
ref="recentFocusRef"
v-model:open="recentFocusOpen"
source="traces"
:anchor="queryEditorBoxRef"
:examples="recentExamples"
@run="(entry, when) => emit('recent-restore', entry, when, true)"
@load="(entry, when) => emit('recent-restore', entry, when, false)"
@view-all="openRecentSearches"
@example="applyRecentExample"
@dismiss="dismissRecentFocus"
/>
<div
v-if="searchObj.data.editorValue == '' && searchObj.meta.queryEditorPlaceholderFlag"
v-if="
searchObj.data.editorValue === '' &&
searchObj.meta.queryEditorPlaceholderFlag &&
!recentFocusOpen
"
class="query-editor-placeholder-overlay pointer-events-none absolute top-0 right-0 bottom-0 left-0 z-1 flex items-start [padding:0.1875rem_0.5rem_0_2.15rem] select-none"
>
<span class="query-editor-placeholder-typewriter">{{ traceEditorPlaceholder }}</span>
</div>
</div>
</div>
<RecentSearchesDrawer
ref="recentDrawerRef"
source="traces"
@restore="(entry, when, run) => emit('recent-restore', entry, when, run)"
/>
<ODialog
v-model:open="saveViewDialogOpen"
size="xs"
@@ -630,6 +676,20 @@ import { useMutation, useQuery } from "@tanstack/vue-query";
import { useOrgId } from "@/composables/query/useOrgId";
import { useConfirmDialog } from "@/composables/useConfirmDialog";
import savedViewsService from "@/services/saved_views";
import RecentSearchesButton from "@/components/queryHistory/RecentSearchesButton.vue";
import RecentSearchesDrawer from "@/components/queryHistory/RecentSearchesDrawer.vue";
import RecentSearchesFocusDropdown from "@/components/queryHistory/RecentSearchesFocusDropdown.vue";
import {
useRecentSearches,
useRecentSearchesList,
} from "@/composables/queryHistory/useRecentSearches";
import {
recentQueryList,
recentsOpenAfterEdit,
routeEditorKeyToRecents,
} from "@/utils/queryHistory/recentSearches";
import { TRACES_EXAMPLE_QUERIES } from "@/utils/queryHistory/traces";
import type { HistoryStatus } from "@/services/query_history";
import {
savedViewsQuery,
createSavedViewMutation,
@@ -661,6 +721,9 @@ export default defineComponent({
SyntaxGuide,
TracesSavedViewsMenuItems,
SavedViewsListDialog,
RecentSearchesButton,
RecentSearchesDrawer,
RecentSearchesFocusDropdown,
},
emits: [
"searchdata",
@@ -673,6 +736,7 @@ export default defineComponent({
"services-catalog-refresh",
"apply-saved-view",
"drill-down",
"recent-restore",
],
props: {
fieldValues: {
@@ -718,9 +782,72 @@ export default defineComponent({
externalErrors: toRef(searchObj.data, "sqlSyntaxErrorRanges"),
});
const queryEditorBoxRef = ref<HTMLElement | null>(null);
const recentFocusRef = ref<InstanceType<typeof RecentSearchesFocusDropdown> | null>(null);
const recentDrawerRef = ref<InstanceType<typeof RecentSearchesDrawer> | null>(null);
const recentFocusOpen = ref(false);
// A typed edit and a recent search loaded without running both leave the shown results stale until the next run.
const showRunQueryPending = computed(() => {
const applied: unknown = searchObj.data.appliedEditorValue;
if (typeof applied !== "string") return false;
return String(searchObj.data.editorValue ?? "").trim() !== applied.trim();
});
const recentListActive = ref(false);
const { enabled: recentSearchesEnabled } = useRecentSearches();
const recentList = useRecentSearchesList("traces", recentListActive);
const recentQueries = computed(() =>
recentSearchesEnabled.value ? recentQueryList(recentList.data.value ?? []) : [],
);
const recentExamples = computed(() =>
searchObj.data.stream.selectedStream?.value ? TRACES_EXAMPLE_QUERIES : [],
);
// Escape in the list hands focus back to the editor, and that focus must not reopen the list.
let recentFocusDismissed = false;
const dismissRecentFocus = () => {
recentFocusDismissed = true;
focusEditor();
};
const onRecentEditorEdit = (isBlank = false, wasBlank = false) => {
recentFocusDismissed = false;
recentFocusOpen.value = recentsOpenAfterEdit(
recentFocusOpen.value,
wasBlank,
recentSearchesEnabled.value && isBlank,
);
};
const onQueryEditorFocus = () => {
searchObj.meta.queryEditorPlaceholderFlag = false;
_sqlOnFocus();
if (recentFocusDismissed) {
recentFocusDismissed = false;
return;
}
if (!recentSearchesEnabled.value) return;
recentListActive.value = true;
if (!String(searchObj.data.editorValue ?? "").trim()) recentFocusOpen.value = true;
};
const focusEditor = () => queryEditorRef.value?.focus?.();
// Capture phase: the editor's Escape binding stops the event, and that binding must still run.
const onRecentEditorKeydown = (e: KeyboardEvent) => {
if (!recentFocusOpen.value) return;
routeEditorKeyToRecents(e, recentFocusRef.value, () => (recentFocusOpen.value = false));
};
const openRecentSearches = (status: HistoryStatus | null = null) => {
if (!recentSearchesEnabled.value) return false;
recentDrawerRef.value?.show(status);
return true;
};
const applyRecentExample = (query: string) => {
searchObj.data.editorValue = query;
setEditorValue(query);
focusEditor();
};
const onQueryEditorBlur = async () => {
searchObj.meta.queryEditorPlaceholderFlag = true;
@@ -1335,6 +1462,20 @@ export default defineComponent({
queryEditorRef,
onQueryEditorFocus,
onQueryEditorBlur,
queryEditorBoxRef,
recentFocusRef,
recentDrawerRef,
recentFocusOpen,
showRunQueryPending,
recentQueries,
recentExamples,
onRecentEditorEdit,
dismissRecentFocus,
onRecentEditorKeydown,
openRecentSearches,
applyRecentExample,
focusEditor,
emit,
updateQueryValue,
updateDateTime,
downloadLogs,
+81 -12
View File
@@ -13,24 +13,35 @@
// 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 { infiniteQueryOptions, mutationOptions } from "@tanstack/vue-query";
import queryHistory, { type QueryHistoryEntry } from "./query_history";
import { infiniteQueryOptions, mutationOptions, queryOptions } from "@tanstack/vue-query";
import queryHistory, {
type QueryHistoryClearParams,
type QueryHistoryEntry,
type QueryHistoryListParams,
type QueryHistoryRecordBody,
} from "./query_history";
import { queryHistoryKeys } from "./query_history.querykeys";
import { MEDIUM_STALE_TIME } from "@/composables/query/cachePolicy";
import { LIVE_STALE_TIME, MEDIUM_STALE_TIME } from "@/composables/query/cachePolicy";
import { queryClient } from "@/composables/query/queryClient";
import { POPOVER_ROWS } from "@/utils/queryHistory/recentSearches";
export const QUERY_HISTORY_PAGE_SIZE = 50;
/** The quick list holds its successful rows plus the newest run, which is pinned when it failed. */
const RECENT_SEARCHES_CACHED_ROWS = POPOVER_ROWS + 1;
export const queryHistoryQuery = (org: string, starred: boolean, q: string) =>
const rowsOf = (data: unknown): QueryHistoryEntry[] =>
Array.isArray(data) ? (data as QueryHistoryEntry[]) : [];
export const queryHistoryQuery = (org: string, filters: QueryHistoryListParams) =>
infiniteQueryOptions({
queryKey: queryHistoryKeys.list(org, starred, q),
queryKey: queryHistoryKeys.list(org, filters),
queryFn: async ({ pageParam }): Promise<QueryHistoryEntry[]> => {
const res = await queryHistory.list(org, {
...(starred ? { starred: true } : {}),
...(q ? { q } : {}),
...filters,
limit: QUERY_HISTORY_PAGE_SIZE,
offset: pageParam,
});
return Array.isArray(res.data) ? res.data : [];
return rowsOf(res.data);
},
initialPageParam: 0,
// A short page is the last one.
@@ -39,12 +50,63 @@ export const queryHistoryQuery = (org: string, starred: boolean, q: string) =>
staleTime: MEDIUM_STALE_TIME,
});
// Only the rows the list shows: each entry carries its context, up to 64 KB.
export const recentSearchesQuery = (org: string, source: string) =>
queryOptions({
queryKey: queryHistoryKeys.recent(org, source),
queryFn: async (): Promise<QueryHistoryEntry[]> => {
const [ok, newest] = await Promise.all([
queryHistory.list(org, { source, status: "ok", limit: POPOVER_ROWS }),
queryHistory.list(org, { source, limit: 1 }),
]);
const rows = [...rowsOf(newest.data), ...rowsOf(ok.data)];
return rows.filter((row, i) => rows.findIndex((r) => r.id === row.id) === i);
},
staleTime: LIVE_STALE_TIME,
});
/** Failed runs of one page, for the quick list's "N failed" link. */
export const recentFailedCountQuery = (org: string, source: string) =>
queryOptions({
queryKey: queryHistoryKeys.list(org, { source, status: "error", count_only: true }),
queryFn: async (): Promise<number> => {
const res = await queryHistory.count(org, { source, status: "error" });
return Number(res.data?.count ?? 0);
},
staleTime: LIVE_STALE_TIME,
});
/** How many entries a clear would delete, counted by the server. */
export const queryHistoryCountQuery = (org: string, source: string, starred: boolean) =>
queryOptions({
queryKey: queryHistoryKeys.list(org, { source, starred, count_only: true }),
queryFn: async (): Promise<number> => {
const res = await queryHistory.count(org, { source, starred });
return Number(res.data?.count ?? 0);
},
staleTime: 0,
});
const isEntry = (data: unknown): data is QueryHistoryEntry =>
!!data && typeof data === "object" && typeof (data as QueryHistoryEntry).id === "string";
// Never toasted: a failed record must not disturb the run it belongs to.
export const recordQueryHistoryMutation = (org: string) =>
mutationOptions({
mutationFn: (body: { query: string; context: Record<string, any> }) =>
queryHistory.record(org, body),
meta: { invalidates: [queryHistoryKeys.all(org)], silentError: true },
mutationFn: (body: QueryHistoryRecordBody) => queryHistory.record(org, body),
// The editor's autocomplete keeps its list loaded, so the stored run goes straight into it instead of a refetch.
onSuccess: (res) => {
const entry: unknown = res?.data;
if (!isEntry(entry)) return;
queryClient.setQueryData<QueryHistoryEntry[]>(
queryHistoryKeys.recent(org, entry.source),
(rows) =>
rows &&
[entry, ...rows.filter((r) => r.id !== entry.id)].slice(0, RECENT_SEARCHES_CACHED_ROWS),
);
},
// Marked stale, not refetched: a list in use refetches when it next opens, not on every run.
meta: { stales: [queryHistoryKeys.all(org)], silentError: true },
});
export const starQueryHistoryMutation = (org: string) =>
@@ -54,8 +116,15 @@ export const starQueryHistoryMutation = (org: string) =>
meta: { invalidates: [queryHistoryKeys.all(org)] },
});
// The org travels with the call: an Undo-able delete commits after the user may have switched org.
export const deleteQueryHistoryMutation = (org: string) =>
mutationOptions({
mutationFn: (id: string) => queryHistory.remove(org, id),
mutationFn: (vars: { org: string; id: string }) => queryHistory.remove(vars.org, vars.id),
meta: { invalidates: [queryHistoryKeys.all(org)] },
});
export const clearQueryHistoryMutation = (org: string) =>
mutationOptions({
mutationFn: (params: QueryHistoryClearParams) => queryHistory.clear(org, params),
meta: { invalidates: [queryHistoryKeys.all(org)] },
});
+6 -4
View File
@@ -14,10 +14,12 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { orgKey } from "@/composables/query/keys";
import type { QueryHistoryListParams } from "./query_history";
/** `all` is the invalidation scope; the list sits beneath it, keyed by its filters. */
/** `all` is the invalidation scope; every list sits beneath it, keyed by its filters. */
export const queryHistoryKeys = {
all: (org: string) => orgKey(org, "metrics", "queryHistory"),
list: (org: string, starred: boolean, q: string) =>
orgKey(org, "metrics", "queryHistory", "list", { starred, q }),
all: (org: string) => orgKey(org, "queryHistory"),
list: (org: string, filters: QueryHistoryListParams) =>
orgKey(org, "queryHistory", "list", filters),
recent: (org: string, source: string) => orgKey(org, "queryHistory", "recent", source),
};
+83 -9
View File
@@ -14,8 +14,11 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it, beforeEach, vi } from "vitest";
import queryHistory from "@/services/query_history";
import queryHistory, { type QueryHistoryEntry } from "@/services/query_history";
import { queryClient } from "@/composables/query/queryClient";
import http from "@/services/http";
import { recordQueryHistoryMutation } from "@/services/query_history.queries";
import { queryHistoryKeys } from "@/services/query_history.querykeys";
vi.mock("@/services/http", () => ({ default: vi.fn() }));
@@ -32,12 +35,17 @@ describe("query_history service", () => {
(http as any).mockReturnValue(api);
});
it("records an entry", async () => {
await queryHistory.record("o1", { query: "up", context: { chart_type: "line" } });
expect(api.post).toHaveBeenCalledWith("/api/o1/query_history", {
query: "up",
context: { chart_type: "line" },
});
it("records a run with its source, key, streams and status", async () => {
const body = {
source: "logs" as const,
query: "level = 'error'",
query_key: '[["k8s"],"logs","filter","level = \'error\'",""]',
streams: ["k8s"],
status: "ok" as const,
context: { time: { type: "relative" as const, period: "15m", start_time: 1, end_time: 2 } },
};
await queryHistory.record("o1", body);
expect(api.post).toHaveBeenCalledWith("/api/o1/query_history", body);
});
it("lists with only the filters that are set", async () => {
@@ -45,8 +53,74 @@ describe("query_history service", () => {
expect(api.get).toHaveBeenCalledWith("/api/o1/query_history", {
params: { starred: true, q: "rate" },
});
await queryHistory.list("o1", {});
expect(api.get).toHaveBeenLastCalledWith("/api/o1/query_history", { params: {} });
await queryHistory.list("o1", {
source: "logs,traces",
status: "error",
stream: "k8s",
ran_from: 1,
ran_to: 2,
});
expect(api.get).toHaveBeenLastCalledWith("/api/o1/query_history", {
params: { source: "logs,traces", status: "error", stream: "k8s", ran_from: 1, ran_to: 2 },
});
});
it("marks recent searches stale after a record instead of refetching them", () => {
const meta = recordQueryHistoryMutation("o1").meta;
expect(meta?.stales).toEqual([queryHistoryKeys.all("o1")]);
expect(meta?.invalidates).toBeUndefined();
});
it("puts a recorded run first in its page's loaded recent list, without a refetch", async () => {
const key = queryHistoryKeys.recent("o1", "logs");
const old = (id: string) => ({ id, source: "logs" }) as QueryHistoryEntry;
queryClient.setQueryData(key, [old("a"), old("b")]);
const options = recordQueryHistoryMutation("o1");
const stored = { ...old("b"), run_count: 2 };
await options.onSuccess?.({ data: stored } as never, {} as never, undefined, {} as never);
expect(queryClient.getQueryData<QueryHistoryEntry[]>(key)?.map((r) => r.id)).toEqual([
"b",
"a",
]);
await options.onSuccess?.({ data: "" } as never, {} as never, undefined, {} as never);
expect(queryClient.getQueryData<QueryHistoryEntry[]>(key)).toHaveLength(2);
const traces = queryHistoryKeys.recent("o1", "traces");
await options.onSuccess?.(
{ data: { id: "t", source: "traces" } } as never,
{} as never,
undefined,
{} as never,
);
expect(queryClient.getQueryData(traces)).toBeUndefined();
queryClient.removeQueries({ queryKey: queryHistoryKeys.all("o1") });
});
it("keeps a recorded run's list to the 8 rows and the pinned run the quick list shows", async () => {
const key = queryHistoryKeys.recent("o1", "logs");
const old = (id: string) => ({ id, source: "logs" }) as QueryHistoryEntry;
queryClient.setQueryData(
key,
Array.from({ length: 9 }, (_, i) => old(`e${i}`)),
);
const options = recordQueryHistoryMutation("o1");
await options.onSuccess?.({ data: old("new") } as never, {} as never, undefined, {} as never);
const ids = queryClient.getQueryData<QueryHistoryEntry[]>(key)?.map((r) => r.id);
expect(ids).toEqual(["new", "e0", "e1", "e2", "e3", "e4", "e5", "e6", "e7"]);
queryClient.removeQueries({ queryKey: queryHistoryKeys.all("o1") });
});
it("asks the server for a count with the list filters", async () => {
await queryHistory.count("o1", { source: "logs", starred: false });
expect(api.get).toHaveBeenCalledWith("/api/o1/query_history", {
params: { source: "logs", starred: false, count_only: true },
});
});
it("clears the caller's history for a scope", async () => {
await queryHistory.clear("o1", { source: "logs", include_starred: false });
expect(api.delete).toHaveBeenCalledWith("/api/o1/query_history", {
params: { source: "logs", include_starred: false },
});
});
it("stars and deletes by id", async () => {
+61 -5
View File
@@ -14,32 +14,88 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import http from "./http";
import type { TimeQueryParams } from "@/utils/dashboard/urlTimeParams";
export type HistorySource = "logs" | "traces" | "metrics" | "dbm" | "profiles";
export type HistoryStatus = "ok" | "error";
/** The window a run used; start and end are microseconds. */
export interface HistoryTime {
type: "relative" | "absolute";
period?: string;
start_time: number;
end_time: number;
}
/** What a page stores to restore a run; the backend never reads it. */
export interface HistoryContext {
page?: Record<string, string>;
time?: HistoryTime;
error?: string;
warning?: string;
time_range?: TimeQueryParams;
step?: string | null;
chart_type?: string;
metrics_data?: string;
}
export interface QueryHistoryEntry {
id: string;
source: HistorySource;
query: string;
context: Record<string, any>;
query_key: string | null;
streams: string[];
context: HistoryContext;
status: HistoryStatus;
starred: boolean;
/** Microseconds since the epoch. */
run_count: number;
/** First run, microseconds since the epoch. */
created_at: number;
/** Latest run, microseconds since the epoch. */
updated_at: number;
}
export interface QueryHistoryRecordBody {
source: HistorySource;
query: string;
query_key: string;
streams: string[];
status: HistoryStatus;
context: HistoryContext;
}
export interface QueryHistoryListParams {
starred?: boolean;
/** One source or a comma-separated list. */
source?: string;
status?: HistoryStatus;
stream?: string;
q?: string;
starred?: boolean;
ran_from?: number;
ran_to?: number;
limit?: number;
offset?: number;
/** Answer `{ count }` for the filters instead of the entries. */
count_only?: boolean;
}
export interface QueryHistoryClearParams {
source?: string;
include_starred?: boolean;
}
const base = (org: string) => `/api/${org}/query_history`;
const entry = (org: string, id: string) => `${base(org)}/${encodeURIComponent(id)}`;
const queryHistory = {
record: (org: string, body: { query: string; context: Record<string, any> }) =>
http().post(base(org), body),
record: (org: string, body: QueryHistoryRecordBody) => http().post(base(org), body),
list: (org: string, params: QueryHistoryListParams) => http().get(base(org), { params }),
count: (org: string, params: QueryHistoryListParams) =>
http().get<{ count: number }>(base(org), { params: { ...params, count_only: true } }),
star: (org: string, id: string, starred: boolean) => http().patch(entry(org, id), { starred }),
remove: (org: string, id: string) => http().delete(entry(org, id)),
clear: (org: string, params: QueryHistoryClearParams) =>
http().delete<{ deleted: number }>(base(org), { params }),
};
export default queryHistory;
+37
View File
@@ -0,0 +1,37 @@
// 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/>.
export interface CaretBox {
left: number;
top: number;
right: number;
bottom: number;
}
export const FIRST_LINE_CARET: CaretBox = { left: 113, top: 91, right: 115, bottom: 109 };
/** Gives Monaco's caret element an on-screen box, since happy-dom lays nothing out and the recents dropdown opens at the caret. */
export const placeCaret = (cursor: Element | null, box: CaretBox = FIRST_LINE_CARET) => {
if (!cursor) return;
cursor.getBoundingClientRect = () =>
({
...box,
x: box.left,
y: box.top,
width: box.right - box.left,
height: box.bottom - box.top,
toJSON: () => ({}),
}) as DOMRect;
};
@@ -0,0 +1,30 @@
// 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 { vi } from "vitest";
import type { RouteLocationRaw, Router } from "vue-router";
/** A router whose push lands at once: it writes the pushed history state, as a real navigation does. */
export const landingRouter = () => {
const push = vi.fn((to: RouteLocationRaw) => {
const state = typeof to === "object" && "state" in to ? to.state : undefined;
window.history.replaceState({ ...(state ?? {}) }, "");
return Promise.resolve();
});
return { router: { push } as unknown as Router, push };
};
/** Moves to a page without a restore, such as a link, a reload or a second tab's navigation. */
export const landWithoutRestore = () => window.history.replaceState({}, "");
+1
View File
@@ -94,6 +94,7 @@ const store = createStore({
savedViewDialog: false,
regionInfo: [],
zoConfig: {
query_history_enabled: true,
service_account_enabled: true,
// Enterprise-shaped by default, which is what the synthetics specs were
// written against. The specs that cover the OSS shape set it to false
+292
View File
@@ -0,0 +1,292 @@
// 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, expect, it } from "vitest";
import type { QueryHistoryEntry } from "@/services/query_history";
import { decodeMetricsConfig } from "@/composables/metrics/metricsUrlState";
import { b64DecodeUnicode } from "@/utils/formatters";
import { buildTracesRecord, tracesRestore, tracesRouteQuery } from "./traces";
import {
buildMetricsRecord,
metricsEditorIsEmpty,
metricsEditorIsEmptyWith,
metricsRestore,
metricsRouteQuery,
metricsRunError,
} from "./metrics";
import { recentSearchRoute } from "./routes";
import { recentCompletionItems } from "./recentCompletions";
const entry = (overrides: Partial<QueryHistoryEntry>): QueryHistoryEntry => ({
id: "e",
source: "metrics",
query: "up",
query_key: null,
streams: [],
context: {},
status: "ok",
starred: false,
run_count: 1,
created_at: 1,
updated_at: 1,
...overrides,
});
describe("Traces rows", () => {
const time = { type: "relative" as const, period: "1h", start_time: 10, end_time: 20 };
it("keys a run by stream, mode and filter", () => {
const body = buildTracesRecord({
stream: "default",
searchMode: "spans",
query: " service_name = 'cart' ",
time,
result: { status: "error", error: "unknown field" },
});
expect(body).toMatchObject({
source: "traces",
query: "service_name = 'cart'",
query_key: '[["default"],"traces","spans","service_name = \'cart\'",""]',
streams: ["default"],
status: "error",
context: { page: { stream: "default", tab: "spans" }, time, error: "unknown field" },
});
});
it("records nothing without a filter or stream", () => {
const input = { searchMode: "traces", time, result: { status: "ok" as const } };
expect(buildTracesRecord({ ...input, stream: "default", query: "" })).toBeNull();
expect(buildTracesRecord({ ...input, stream: "", query: "a = 1" })).toBeNull();
});
it("restores now over the period and at original time over the window", () => {
const row = entry({
source: "traces",
query: "a = 1",
streams: ["default"],
context: { page: { stream: "default", tab: "spans" }, time },
});
expect(tracesRestore(row, "now")).toEqual({
stream: "default",
searchMode: "spans",
query: "a = 1",
window: { type: "relative", period: "1h" },
});
const link = tracesRouteQuery(row, "original", "org1");
expect(link).toMatchObject({ stream: "default", tab: "spans", from: "10", to: "20" });
expect(b64DecodeUnicode(link.query)).toBe("a = 1");
expect(recentSearchRoute(row, "original", "org1", "run")).toMatchObject({
path: "/traces",
query: { reload: "true" },
});
});
});
describe("Metrics rows", () => {
const panel = {
data: {
type: "line",
queryType: "promql",
config: { step_value: "30s" },
queries: [{ query: "up", fields: { stream: "up" } }],
},
layout: { hiddenQueries: [] as number[] },
};
it("keeps the v1.1.x context and adds the resolved window", () => {
const body = buildMetricsRecord({
panel,
selectedDate: { valueType: "relative", relativeTimePeriod: "6h" },
window: { startUs: 100, endUs: 200 },
});
expect(body).toMatchObject({
source: "metrics",
query: "up",
query_key: '[["up"],"metrics","promql","up",""]',
streams: ["up"],
context: {
time_range: { period: "6h" },
step: "30s",
chart_type: "line",
time: { type: "relative", period: "6h", start_time: 100, end_time: 200 },
},
});
expect(decodeMetricsConfig(body!.context.metrics_data)?.data.queries[0].query).toBe("up");
});
it("reopens a v1.1.x row (no time, no key) at its stored range either way", () => {
const legacy = entry({
context: { metrics_data: "blob", time_range: { period: "6h" }, chart_type: "bar" },
});
for (const when of ["now", "original"] as const) {
expect(metricsRestore(legacy, when)).toMatchObject({
metricsData: "blob",
timeRange: { valueType: "relative", relativeTimePeriod: "6h" },
});
}
const absolute = entry({
context: { metrics_data: "blob", time_range: { from: 1000, to: 2000 } },
});
expect(metricsRestore(absolute, "original")?.timeRange).toMatchObject({
valueType: "absolute",
startTime: 1000,
endTime: 2000,
});
expect(metricsRestore(entry({ context: {} }), "now")).toBeNull();
});
it("reopens a new row at its own window at original time", () => {
const row = entry({
context: {
metrics_data: "blob",
time_range: { period: "6h" },
time: { type: "relative", period: "6h", start_time: 5_000_000, end_time: 9_000_000 },
},
});
expect(metricsRestore(row, "original")?.timeRange).toMatchObject({
valueType: "absolute",
startTime: 5_000_000,
endTime: 9_000_000,
});
expect(metricsRestore(row, "now")?.timeRange).toMatchObject({ relativeTimePeriod: "6h" });
expect(metricsRouteQuery(row, "original", "org1")).toEqual({
org_identifier: "org1",
metrics_data: "blob",
from: "5000",
to: "9000",
});
});
});
describe("Metrics editor and panel errors", () => {
it("treats a panel as empty only when no visible query or formula has text", () => {
expect(metricsEditorIsEmpty({ data: { queries: [{ query: " " }] } })).toBe(true);
expect(metricsEditorIsEmpty({ data: { queries: [{ query: "" }, { query: "up" }] } })).toBe(
false,
);
expect(
metricsEditorIsEmpty({
data: { queries: [{ query: "" }, { query: "up" }] },
layout: { hiddenQueries: [1] },
}),
).toBe(true);
expect(metricsEditorIsEmpty({ data: { queries: [{ config: { formula: "A / B" } }] } })).toBe(
false,
);
});
it("reads a panel error code as a search code or an HTTP status", () => {
expect(metricsRunError({ message: "bad", code: 400 })).toEqual({
httpStatus: 400,
message: "bad",
});
expect(metricsRunError({ message: "slow", code: "20010" })).toEqual({
code: 20010,
message: "slow",
});
expect(metricsRunError({ message: "odd", code: "" })).toEqual({ message: "odd" });
expect(metricsRunError({ message: "", code: "" })).toBeNull();
expect(metricsRunError(null)).toBeNull();
});
});
describe("autocomplete clock rows", () => {
const model = (text: string) => {
const lines = text.split("\n");
return {
getValue: () => text,
getLineCount: () => lines.length,
getLineMaxColumn: (n: number) => lines[n - 1].length + 1,
getLineContent: (n: number) => lines[n - 1],
};
};
it("replaces the whole editor text with a matching recent query", () => {
const items = recentCompletionItems(
["level = 'error' and ns = 'checkout'"],
model("lev"),
{ lineNumber: 1, column: 4 },
18,
"Recent search",
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
insertText: "level = 'error' and ns = 'checkout'",
filterText: "lev",
kind: 18,
range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 4 },
additionalTextEdits: [],
});
expect(items[0].sortText < "a").toBe(true);
});
it("clears the other lines of a multi-line editor", () => {
const items = recentCompletionItems(
["SELECT * FROM a WHERE b = 1"],
model("SELECT *\nFROM a"),
{ lineNumber: 2, column: 7 },
18,
"Recent search",
);
expect(items[0].additionalTextEdits).toEqual([
{
range: { startLineNumber: 1, startColumn: 1, endLineNumber: 2, endColumn: 1 },
text: "",
},
]);
});
it("offers nothing when no recent query matches", () => {
expect(
recentCompletionItems(["up"], model("rate"), { lineNumber: 1, column: 5 }, 18, "x"),
).toEqual([]);
});
});
describe("metrics editor emptiness while typing", () => {
const panel = (
queries: { query?: string; formula?: string }[],
current = 0,
hidden: number[] = [],
) => ({
data: { queries: queries.map((q) => ({ query: q.query, config: { formula: q.formula } })) },
layout: { currentQueryIndex: current, hiddenQueries: hidden },
});
it("reads the edited query from the editor text, not the debounced panel", () => {
expect(metricsEditorIsEmptyWith(panel([{ query: "up" }]), true)).toBe(true);
expect(metricsEditorIsEmptyWith(panel([{ query: "" }]), false)).toBe(false);
});
it("still needs every other visible query and formula empty", () => {
expect(metricsEditorIsEmptyWith(panel([{ query: "" }, { query: "rate(x[5m])" }]), true)).toBe(
false,
);
expect(
metricsEditorIsEmptyWith(panel([{ query: "" }, { query: "rate(x[5m])" }], 0, [1]), true),
).toBe(true);
expect(metricsEditorIsEmptyWith(panel([{ query: "" }, { formula: "A+B" }]), true)).toBe(false);
});
it("treats the editor text as the whole active entry, so clearing the active formula counts", () => {
expect(metricsEditorIsEmptyWith(panel([{ query: "x", formula: "A+B" }]), true)).toBe(true);
expect(metricsEditorIsEmptyWith(panel([{ query: "" }, { formula: "A*2" }], 1), true)).toBe(
true,
);
expect(metricsEditorIsEmptyWith(panel([{ query: "" }, { formula: "A*2" }], 1), false)).toBe(
false,
);
});
});
+211
View File
@@ -0,0 +1,211 @@
// 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, expect, it } from "vitest";
import type { LogsSignature, RunReason } from "@/composables/useLogs/useAutoRun";
import type { QueryHistoryEntry } from "@/services/query_history";
import { b64DecodeUnicode } from "@/utils/formatters";
import {
buildLogsRecord,
isRecordedLogsRun,
isUntouchedLogsQuery,
LOGS_REAPPLY_TYPE,
logsRouteQuery,
logsRunResult,
logsReapplyRequest,
requestLogsReapply,
} from "./logs";
const signature = (overrides: Partial<LogsSignature> = {}): LogsSignature => ({
query: "level = 'error'",
sqlMode: false,
streams: ["k8s_logs"],
streamType: "logs",
time: { type: "relative", period: "15m" },
transform: null,
quickMode: true,
quickModeFields: [],
regions: [],
clusters: [],
effectiveSortOrder: "desc",
definedSchemas: "user_defined_schema",
...overrides,
});
describe("which Logs runs record", () => {
it.each<RunReason>([
"run",
"explicit",
"run-anyway",
"filter",
"stream",
"function",
"time",
"time-typed",
"zoom",
"narrow",
"saved-view",
])("records a %s run", (reason) => {
expect(isRecordedLogsRun([reason])).toBe(true);
});
it.each<RunReason>([
"refresh",
"pagination",
"page-size",
"sort",
"histogram",
"tab",
"patterns",
"landing",
"url",
"activation",
"visualize-restore",
])("never records a %s run", (reason) => {
expect(isRecordedLogsRun([reason])).toBe(false);
});
it("records a refresh tick coalesced with a filter click", () => {
expect(isRecordedLogsRun(["refresh", "filter"])).toBe(true);
});
it("skips the untouched SQL template on a stream or time change, but not on Run", () => {
const template = { query: 'SELECT * FROM "k8s_logs"', sqlMode: true };
expect(isRecordedLogsRun(["stream"], template)).toBe(false);
expect(isRecordedLogsRun(["time", "filter"], template)).toBe(false);
expect(isRecordedLogsRun(["run"], template)).toBe(true);
expect(isRecordedLogsRun(["stream", "explicit"], template)).toBe(true);
expect(isRecordedLogsRun(["refresh"], template)).toBe(false);
const edited = { query: 'SELECT * FROM "k8s_logs" WHERE a = 1', sqlMode: true };
expect(isRecordedLogsRun(["stream"], edited)).toBe(true);
});
it("maps a settled run: failure, cancel, partial", () => {
expect(logsRunResult({ partial: false, error: null })).toEqual({ status: "ok" });
expect(logsRunResult({ partial: true, error: null })).toEqual({ status: "ok", warning: "" });
expect(logsRunResult({ partial: false, error: { code: 20009 } })).toBeNull();
expect(logsRunResult({ partial: false, error: { code: 20001, message: "bad" } })).toEqual({
status: "error",
error: "bad",
});
});
});
describe("the Logs record", () => {
it("keys the run by streams, mode, query and VRL, and keeps the page state", () => {
const body = buildLogsRecord({
signature: signature({ transform: ".a = 1" }),
startUs: 100,
endUs: 200,
columns: ["msg", "level"],
result: { status: "ok" },
});
expect(body).toMatchObject({
source: "logs",
query: "level = 'error'",
query_key: '[["k8s_logs"],"logs","filter","level = \'error\'",".a = 1"]',
streams: ["k8s_logs"],
status: "ok",
context: {
time: { type: "relative", period: "15m", start_time: 100, end_time: 200 },
page: {
stream_type: "logs",
stream: "k8s_logs",
sql_mode: "false",
fn_editor: "true",
},
},
});
expect(b64DecodeUnicode(body!.context.page!.functionContent)).toBe(".a = 1");
expect(body!.context.page!.columns).toBeTruthy();
});
it("stores the error of a failed run and keeps no columns in SQL mode", () => {
const body = buildLogsRecord({
signature: signature({ sqlMode: true, query: 'SELECT * FROM "x" WHERE' }),
startUs: 1,
endUs: 2,
columns: ["msg"],
result: { status: "error", error: "syntax error" },
});
expect(body?.status).toBe("error");
expect(body?.context.error).toBe("syntax error");
expect(body?.context.page?.columns).toBeUndefined();
});
it("records nothing for an empty query", () => {
const body = buildLogsRecord({
signature: signature({ query: " " }),
startUs: 1,
endUs: 2,
columns: [],
result: { status: "ok" },
});
expect(body).toBeNull();
});
});
describe("restoring a Logs row", () => {
const entry: QueryHistoryEntry = {
id: "e",
source: "logs",
query: "level = 'error'",
query_key: "k",
streams: ["k8s_logs"],
context: {
page: { stream: "k8s_logs", stream_type: "logs", sql_mode: "false" },
time: { type: "relative", period: "1h", start_time: 1000, end_time: 2000 },
},
status: "ok",
starred: false,
run_count: 1,
created_at: 1,
updated_at: 2,
};
it("re-runs now over the relative period", () => {
const query = logsRouteQuery(entry, "now", "org1", true);
expect(query).toMatchObject({
stream: "k8s_logs",
org_identifier: "org1",
period: "1h",
type: LOGS_REAPPLY_TYPE,
});
expect(query.from).toBeUndefined();
expect(b64DecodeUnicode(query.query)).toBe("level = 'error'");
});
it("re-runs at original time over the absolute window, and a link carries no re-apply type", () => {
const query = logsRouteQuery(entry, "original", "org1", false);
expect(query).toMatchObject({ from: "1000", to: "2000" });
expect(query.period).toBeUndefined();
expect(query.type).toBeUndefined();
});
it("makes every restore a new request that carries its own run flag", () => {
const before = logsReapplyRequest.value.seq;
requestLogsReapply(true);
expect(logsReapplyRequest.value).toEqual({ seq: before + 1, run: true });
requestLogsReapply(false);
expect(logsReapplyRequest.value).toEqual({ seq: before + 2, run: false });
});
it("treats an empty editor and the untouched SQL template as empty", () => {
expect(isUntouchedLogsQuery("", false)).toBe(true);
expect(isUntouchedLogsQuery('SELECT * FROM "k8s_logs"', true)).toBe(true);
expect(isUntouchedLogsQuery('SELECT * FROM "k8s_logs" WHERE a = 1', true)).toBe(false);
expect(isUntouchedLogsQuery("level = 'error'", false)).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More