mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 00:08:55 +00:00
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:
co-authored by
github-actions[bot]
parent
aaf70b0744
commit
3b2eca2d42
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
] {
|
||||
|
||||
@@ -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(¶ms).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, ¶ms).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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+1046
-310
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 });
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+43
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": "تراجع"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "元に戻す"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "실행 취소"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Отменить"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "撤销"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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: "" });
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)] },
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({}, "");
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user