From 3b2eca2d422fe736543864580f6d3ccbf0d76bb0 Mon Sep 17 00:00:00 2001 From: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:21:16 +0000 Subject: [PATCH] feat: recent searches on Logs, Traces and Metrics (#15912) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/translations/.translation_state.json | 75 +- src/api/http/src/handler/http/router/mod.rs | 25 +- .../http/src/handler/http/router/openapi.rs | 5 + .../management/src/request/query_history.rs | 720 +++++++-- src/api/management/src/request/status/mod.rs | 20 + src/config/src/config.rs | 5 +- src/core/src/traces/service_graph/api.rs | 244 ++- .../src/traces/service_graph/v4/history.rs | 12 +- src/core/src/traces/service_graph/v4/read.rs | 2 +- src/core/src/users.rs | 25 +- src/infra/src/table/entity/query_history.rs | 13 + .../m20261027_000001_alter_query_history.rs | 644 ++++++++ src/infra/src/table/migration/mod.rs | 17 + src/infra/src/table/query_history.rs | 1356 +++++++++++++---- .../pages/metricsPages/metricsPage.js | 35 +- .../Metrics/metrics-query-history.spec.js | 32 +- web/src/components/CodeQueryEditor.spec.ts | 38 + web/src/components/CodeQueryEditor.vue | 37 +- web/src/components/QueryEditor.vue | 6 +- .../PanelEditor/PanelEditor.spec.ts | 25 +- .../dashboards/PanelEditor/PanelEditor.vue | 10 +- .../PanelEditor/types/panelEditor.ts | 3 + ...hboardQueryEditor.completionInputs.spec.ts | 44 +- .../addPanel/DashboardQueryEditor.vue | 27 +- .../queryHistory/RecentSearchesButton.spec.ts | 37 + .../queryHistory/RecentSearchesButton.vue | 112 ++ .../RecentSearchesDayPicker.spec.ts | 129 ++ .../queryHistory/RecentSearchesDayPicker.vue | 136 ++ .../queryHistory/RecentSearchesDetails.vue | 321 ++++ .../queryHistory/RecentSearchesDrawer.spec.ts | 720 +++++++++ .../RecentSearchesDrawer.timezone.spec.ts | 109 ++ .../queryHistory/RecentSearchesDrawer.vue | 653 ++++++++ ...entSearchesFocusDropdown.editorBox.spec.ts | 210 +++ .../RecentSearchesFocusDropdown.spec.ts | 376 +++++ .../RecentSearchesFocusDropdown.vue | 219 +++ .../RecentSearchesPopover.spec.ts | 1287 ++++++++++++++++ .../queryHistory/RecentSearchesPopover.vue | 435 ++++++ .../metrics/useQueryHistoryRecorder.ts | 88 +- web/src/composables/query/queryClient.spec.ts | 28 + web/src/composables/query/queryClient.ts | 5 + .../queryHistory/useLogsHistoryRecorder.ts | 42 + .../queryHistory/useRecentEntryText.spec.ts | 93 ++ .../queryHistory/useRecentEntryText.ts | 64 + .../queryHistory/useRecentSearches.ts | 102 ++ .../useRecentSearchesEditorHost.spec.ts | 158 ++ .../useRecentSearchesEditorHost.ts | 86 ++ .../composables/useLogs/logsAutoRun.spec.ts | 62 + web/src/composables/useLogs/logsAutoRun.ts | 79 +- .../useLogs/useSearchStream.history.spec.ts | 172 +++ .../composables/useLogs/useSearchStream.ts | 8 +- web/src/composables/useTraces.ts | 1 + .../core/Typography/OTruncatedText.spec.ts | 18 + .../core/Typography/OTruncatedText.types.ts | 2 + .../lib/core/Typography/OTruncatedText.vue | 8 +- web/src/lib/feedback/Toast/useToast.spec.ts | 6 + web/src/lib/feedback/Toast/useToast.ts | 8 +- web/src/lib/overlay/Popover/OPopover.spec.ts | 18 + web/src/lib/overlay/Popover/OPopover.vue | 6 + web/src/locales/languages/ar-SA.json | 75 +- web/src/locales/languages/de-DE.json | 75 +- web/src/locales/languages/en-US.json | 75 +- web/src/locales/languages/es-ES.json | 75 +- web/src/locales/languages/fr-FR.json | 75 +- web/src/locales/languages/it-IT.json | 75 +- web/src/locales/languages/ja-JP.json | 75 +- web/src/locales/languages/ko-KR.json | 75 +- web/src/locales/languages/nl-NL.json | 75 +- web/src/locales/languages/pl-PL.json | 75 +- web/src/locales/languages/pt-PT.json | 75 +- web/src/locales/languages/ru-RU.json | 75 +- web/src/locales/languages/tr-TR.json | 75 +- web/src/locales/languages/vi-VN.json | 75 +- web/src/locales/languages/zh-CN.json | 75 +- web/src/locales/languages/zh-TW.json | 75 +- .../plugins/logs/Index.recentReapply.spec.ts | 271 ++++ web/src/plugins/logs/Index.vue | 32 +- .../logs/SearchBar.recentFocusEscape.spec.ts | 255 ++++ web/src/plugins/logs/SearchBar.vue | 173 ++- web/src/plugins/metrics/Index.history.spec.ts | 202 ++- web/src/plugins/metrics/Index.vue | 69 +- .../metrics/QueryHistoryDrawer.spec.ts | 278 ++-- .../plugins/metrics/QueryHistoryDrawer.vue | 227 +-- .../metrics/explorer/MetricsExplorer.spec.ts | 16 +- .../metrics/explorer/MetricsExplorer.vue | 44 +- .../MetricsVisualize.historyStatus.spec.ts | 251 +++ .../metrics/explorer/MetricsVisualize.spec.ts | 56 +- .../metrics/explorer/MetricsVisualize.vue | 18 +- web/src/plugins/traces/Index.spec.ts | 141 ++ web/src/plugins/traces/Index.vue | 106 +- web/src/plugins/traces/SearchBar.spec.ts | 121 +- web/src/plugins/traces/SearchBar.vue | 159 +- web/src/services/query_history.queries.ts | 93 +- web/src/services/query_history.querykeys.ts | 10 +- web/src/services/query_history.spec.ts | 92 +- web/src/services/query_history.ts | 66 +- web/src/test/unit/helpers/monacoCaret.ts | 37 + web/src/test/unit/helpers/recentLanding.ts | 30 + web/src/test/unit/helpers/store.ts | 1 + web/src/utils/queryHistory/adapters.spec.ts | 292 ++++ web/src/utils/queryHistory/logs.spec.ts | 211 +++ web/src/utils/queryHistory/logs.ts | 179 +++ web/src/utils/queryHistory/metrics.ts | 190 +++ .../utils/queryHistory/observeMoves.spec.ts | 150 ++ web/src/utils/queryHistory/observeMoves.ts | 81 + .../queryHistory/queryHistoryContract.spec.ts | 241 +++ .../utils/queryHistory/recentCompletions.ts | 100 ++ .../utils/queryHistory/recentSearches.spec.ts | 521 +++++++ web/src/utils/queryHistory/recentSearches.ts | 313 ++++ web/src/utils/queryHistory/routes.spec.ts | 131 ++ web/src/utils/queryHistory/routes.ts | 95 ++ web/src/utils/queryHistory/traces.ts | 96 ++ 111 files changed, 14710 insertions(+), 1056 deletions(-) create mode 100644 src/infra/src/table/migration/m20261027_000001_alter_query_history.rs create mode 100644 web/src/components/queryHistory/RecentSearchesButton.spec.ts create mode 100644 web/src/components/queryHistory/RecentSearchesButton.vue create mode 100644 web/src/components/queryHistory/RecentSearchesDayPicker.spec.ts create mode 100644 web/src/components/queryHistory/RecentSearchesDayPicker.vue create mode 100644 web/src/components/queryHistory/RecentSearchesDetails.vue create mode 100644 web/src/components/queryHistory/RecentSearchesDrawer.spec.ts create mode 100644 web/src/components/queryHistory/RecentSearchesDrawer.timezone.spec.ts create mode 100644 web/src/components/queryHistory/RecentSearchesDrawer.vue create mode 100644 web/src/components/queryHistory/RecentSearchesFocusDropdown.editorBox.spec.ts create mode 100644 web/src/components/queryHistory/RecentSearchesFocusDropdown.spec.ts create mode 100644 web/src/components/queryHistory/RecentSearchesFocusDropdown.vue create mode 100644 web/src/components/queryHistory/RecentSearchesPopover.spec.ts create mode 100644 web/src/components/queryHistory/RecentSearchesPopover.vue create mode 100644 web/src/composables/queryHistory/useLogsHistoryRecorder.ts create mode 100644 web/src/composables/queryHistory/useRecentEntryText.spec.ts create mode 100644 web/src/composables/queryHistory/useRecentEntryText.ts create mode 100644 web/src/composables/queryHistory/useRecentSearches.ts create mode 100644 web/src/composables/queryHistory/useRecentSearchesEditorHost.spec.ts create mode 100644 web/src/composables/queryHistory/useRecentSearchesEditorHost.ts create mode 100644 web/src/composables/useLogs/useSearchStream.history.spec.ts create mode 100644 web/src/plugins/logs/Index.recentReapply.spec.ts create mode 100644 web/src/plugins/logs/SearchBar.recentFocusEscape.spec.ts create mode 100644 web/src/plugins/metrics/explorer/MetricsVisualize.historyStatus.spec.ts create mode 100644 web/src/test/unit/helpers/monacoCaret.ts create mode 100644 web/src/test/unit/helpers/recentLanding.ts create mode 100644 web/src/utils/queryHistory/adapters.spec.ts create mode 100644 web/src/utils/queryHistory/logs.spec.ts create mode 100644 web/src/utils/queryHistory/logs.ts create mode 100644 web/src/utils/queryHistory/metrics.ts create mode 100644 web/src/utils/queryHistory/observeMoves.spec.ts create mode 100644 web/src/utils/queryHistory/observeMoves.ts create mode 100644 web/src/utils/queryHistory/queryHistoryContract.spec.ts create mode 100644 web/src/utils/queryHistory/recentCompletions.ts create mode 100644 web/src/utils/queryHistory/recentSearches.spec.ts create mode 100644 web/src/utils/queryHistory/recentSearches.ts create mode 100644 web/src/utils/queryHistory/routes.spec.ts create mode 100644 web/src/utils/queryHistory/routes.ts create mode 100644 web/src/utils/queryHistory/traces.ts diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index 82564e97f7..d1f8904e4c 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -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", diff --git a/src/api/http/src/handler/http/router/mod.rs b/src/api/http/src/handler/http/router/mod.rs index 858181fa10..22f18ce6bf 100644 --- a/src/api/http/src/handler/http/router/mod.rs +++ b/src/api/http/src/handler/http/router/mod.rs @@ -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() { diff --git a/src/api/http/src/handler/http/router/openapi.rs b/src/api/http/src/handler/http/router/openapi.rs index 25cdcfed5e..4795c10455 100644 --- a/src/api/http/src/handler/http/router/openapi.rs +++ b/src/api/http/src/handler/http/router/openapi.rs @@ -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"), ] { diff --git a/src/api/management/src/request/query_history.rs b/src/api/management/src/request/query_history.rs index 81be8fcbaf..b48d88d9d0 100644 --- a/src/api/management/src/request/query_history.rs +++ b/src/api/management/src/request/query_history.rs @@ -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, /// 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, + /// Streams the query ran on, at most 50; each name non-empty and without a comma. + #[serde(default)] + pub streams: Vec, + /// `ok` or `error`; defaults to `ok`. + #[serde(default)] + pub status: Option, + /// 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, + /// Streams the query ran on. + pub streams: Vec, + /// 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 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 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, + /// `ok` or `error`. + pub status: Option, + /// Exact stream name the entry ran on. + pub stream: Option, + /// Case-insensitive substring of the query text or the stream names. + pub q: Option, /// Only starred (`true`) or only unstarred (`false`) entries. pub starred: Option, - /// Substring match on the query text. - pub q: Option, + /// Entries last run at or after this time, in microseconds. + pub ran_from: Option, + /// Entries last run at or before this time, in microseconds. + pub ran_to: Option, /// Defaults to 50, at most 200. pub limit: Option, - /// Entries to skip, for paging. Defaults to 0. + /// Entries to skip, for paging. Defaults to 0, at most 9223372036854775807. pub offset: Option, + /// Answer `{"count": n}`, the number of entries the filters match, instead of the entries. + pub count_only: Option, } -fn list_limit(limit: Option) -> 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, + /// Also delete starred entries. Defaults to false. + pub include_starred: Option, +} + +#[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), + 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 { 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 { 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), + (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, + Headers(user_email): Headers, + Query(params): Query, +) -> 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, ) -> 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 { + limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT) +} + +/// The databases bind OFFSET as a signed 64-bit integer. +fn list_offset(offset: Option) -> Result { + 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, 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, 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 = 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::::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::>(), - ), + 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 { + // 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 { - 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(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) -> Vec { + 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| { - rows.iter() - .map(|e| e["query"].as_str().unwrap().to_string()) - .collect::>() - }; 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()); + } } diff --git a/src/api/management/src/request/status/mod.rs b/src/api/management/src/request/status/mod.rs index c96ae67ab5..a337a2d354 100644 --- a/src/api/management/src/request/status/mod.rs +++ b/src/api/management/src/request/status/mod.rs @@ -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") { diff --git a/src/config/src/config.rs b/src/config/src/config.rs index dfb4b3c6a3..4f575bbb28 100644 --- a/src/config/src/config.rs +++ b/src/config/src/config.rs @@ -126,7 +126,8 @@ pub type RwBTreeMap = tokio::sync::RwLock>; // 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 diff --git a/src/core/src/traces/service_graph/api.rs b/src/core/src/traces/service_graph/api.rs index ce39ce0b0d..ac4ab8a9c5 100644 --- a/src/core/src/traces/service_graph/api.rs +++ b/src/core/src/traces/service_graph/api.rs @@ -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, axum::extract::Query(query): axum::extract::Query, ) -> 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| 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, + end: Option, + 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 + ); + } } diff --git a/src/core/src/traces/service_graph/v4/history.rs b/src/core/src/traces/service_graph/v4/history.rs index e9b2cf04c0..a817003421 100644 --- a/src/core/src/traces/service_graph/v4/history.rs +++ b/src/core/src/traces/service_graph/v4/history.rs @@ -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); diff --git a/src/core/src/traces/service_graph/v4/read.rs b/src/core/src/traces/service_graph/v4/read.rs index 42edd7c629..6e487c85fe 100644 --- a/src/core/src/traces/service_graph/v4/read.rs +++ b/src/core/src/traces/service_graph/v4/read.rs @@ -257,7 +257,7 @@ where Fut: Future, 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; diff --git a/src/core/src/users.rs b/src/core/src/users.rs index ba1641cad1..31a6953ec9 100644 --- a/src/core/src/users.rs +++ b/src/core/src/users.rs @@ -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() diff --git a/src/infra/src/table/entity/query_history.rs b/src/infra/src/table/entity/query_history.rs index 1aa738ed85..9e24535a06 100644 --- a/src/infra/src/table/entity/query_history.rs +++ b/src/infra/src/table/entity/query_history.rs @@ -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, + /// 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)] diff --git a/src/infra/src/table/migration/m20261027_000001_alter_query_history.rs b/src/infra/src/table/migration/m20261027_000001_alter_query_history.rs new file mode 100644 index 0000000000..07f071eb54 --- /dev/null +++ b/src/infra/src/table/migration/m20261027_000001_alter_query_history.rs @@ -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 . + +//! 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, + 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 { + 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> { + 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::>("", "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![("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> { + 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::(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![("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"); + } +} diff --git a/src/infra/src/table/migration/mod.rs b/src/infra/src/table/migration/mod.rs index de27c92e44..a8a71c82a6 100644 --- a/src/infra/src/table/migration/mod.rs +++ b/src/infra/src/table/migration/mod.rs @@ -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 diff --git a/src/infra/src/table/query_history.rs b/src/infra/src/table/query_history.rs index 0e99051af3..a83d21635e 100644 --- a/src/infra/src/table/query_history.rs +++ b/src/infra/src/table/query_history.rs @@ -14,199 +14,173 @@ // along with this program. If not, see . use sea_orm::{ - ColumnTrait, Condition, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, - QuerySelect, Set, TransactionTrait, - sea_query::{Expr, Func, LikeExpr, SimpleExpr}, + ColumnTrait, Condition, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, + PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, QueryTrait, Set, Statement, + TransactionTrait, + sea_query::{Expr, Func, LikeExpr, OnConflict, Query, SimpleExpr}, }; use super::entity::query_history::{ActiveModel, Column, Entity, Model}; use crate::{ db::{get_orm_client_ro, get_orm_client_rw}, - errors, + errors, orm_err, }; -/// Recording prunes the user's oldest unstarred entries beyond this many. -pub const MAX_UNSTARRED_PER_USER: u64 = 1000; -/// Starring past this many is refused; recording never stars, so it is unaffected. +/// Recording a new row prunes the user's oldest unstarred rows of that source beyond this many. +pub const MAX_UNSTARRED_PER_SOURCE: u64 = 500; +/// Starring past this many across all sources is refused; recording never stars. pub const MAX_STARRED_PER_USER: u64 = 200; +/// Pages that record history; the handler refuses any other source. +pub const SOURCES: [&str; 5] = ["logs", "traces", "metrics", "dbm", "profiles"]; +/// What v1.1.x rows and clients that send no source are. +pub const DEFAULT_SOURCE: &str = "metrics"; +pub const STATUS_OK: &str = "ok"; +pub const STATUS_ERROR: &str = "error"; /// Outcome of starring or unstarring an entry. pub enum SetStarred { - Updated(Model), + Updated(Box), NotFound, /// Starring would take the user past `MAX_STARRED_PER_USER`. StarredCapReached, } -/// Records a query for this org and user; the same query as their latest entry refreshes it. -pub async fn record( - org_id: &str, - user_email: &str, - query: &str, - context: serde_json::Value, - now_us: i64, -) -> Result { - let client = get_orm_client_rw().await; - record_with(client, org_id, user_email, query, context, now_us).await +/// One run to record; repeats of the same source and key merge into one row. +pub struct NewRun<'a> { + pub org_id: &'a str, + pub user_email: &'a str, + pub source: &'a str, + pub query: &'a str, + /// The client's merge key; `None` or empty falls back to the query text. + pub query_key: Option<&'a str>, + pub streams: &'a [String], + pub status: &'a str, + pub context: serde_json::Value, + pub now_us: i64, } -pub async fn record_with( +/// List filters; every `None` or empty field matches all rows. +#[derive(Default)] +pub struct ListFilter { + pub sources: Vec, + pub status: Option, + /// Exact stream name. + pub stream: Option, + /// Case-insensitive literal substring of the query or the streams. + pub q: Option, + pub starred: Option, + /// Inclusive bounds on `updated_at`, in microseconds. + pub ran_from: Option, + pub ran_to: Option, +} + +/// Records a run: inserts a row, or merges into the row with the same org, user, source and key. +pub async fn record(run: NewRun<'_>) -> Result { + let client = get_orm_client_rw().await; + record_with(client, run).await +} + +pub async fn record_with( conn: &C, - org_id: &str, - user_email: &str, - query: &str, - context: serde_json::Value, - now_us: i64, + run: NewRun<'_>, ) -> Result { - let txn = conn.begin().await?; - let latest = Entity::find() - .filter(Column::OrgId.eq(org_id)) - .filter(Column::UserEmail.eq(user_email)) - .select_only() - .column(Column::Id) - .column(Column::Query) - .column(Column::Starred) - .order_by_desc(Column::CreatedAt) - .order_by_desc(Column::Id) - .into_tuple::<(String, String, bool)>() - .one(&txn) - .await?; - let model = match latest { - Some((id, latest_query, starred)) if latest_query == query => { - Entity::update_many() - .col_expr(Column::CreatedAt, Expr::value(now_us)) - .col_expr(Column::Context, Expr::value(context.clone())) - .filter(Column::Id.eq(&id)) - .exec(&txn) - .await?; - Model { - id, - org_id: org_id.to_string(), - user_email: user_email.to_string(), - query: latest_query, - context, - starred, - created_at: now_us, - } - } - _ => { - let model = Model { - id: config::ider::uuid(), - org_id: org_id.to_string(), - user_email: user_email.to_string(), - query: query.to_string(), - context, - starred: false, - created_at: now_us, - }; - Entity::insert(ActiveModel { - id: Set(model.id.clone()), - org_id: Set(model.org_id.clone()), - user_email: Set(model.user_email.clone()), - query: Set(model.query.clone()), - context: Set(model.context.clone()), - starred: Set(false), - created_at: Set(now_us), - }) - .exec(&txn) - .await?; - prune_unstarred_over_cap(&txn, org_id, user_email).await?; - model - } + let entry = ActiveModel { + id: Set(config::ider::uuid()), + org_id: Set(run.org_id.to_string()), + user_email: Set(run.user_email.to_string()), + query: Set(run.query.to_string()), + context: Set(run.context), + starred: Set(false), + created_at: Set(run.now_us), + source: Set(run.source.to_string()), + query_key: Set(Some(query_key_hash(run.query_key, run.query))), + streams: Set(encode_streams(run.streams)), + run_count: Set(1), + updated_at: Set(run.now_us), + status: Set(run.status.to_string()), }; - txn.commit().await?; + // One statement, so concurrent identical runs merge instead of racing to insert. + let mut stmt = Entity::insert(entry) + .on_conflict( + OnConflict::columns([ + Column::OrgId, + Column::UserEmail, + Column::Source, + Column::QueryKey, + ]) + .value( + Column::RunCount, + Expr::col((Entity, Column::RunCount)).add(1), + ) + .update_columns([Column::UpdatedAt, Column::Context, Column::Status]) + .to_owned(), + ) + .into_query(); + stmt.returning(Query::returning().all()); + + let Some(row) = conn + .query_one(conn.get_database_backend().build(&stmt)) + .await? + else { + return orm_err!("query history upsert returned no row"); + }; + let model = Model::from_query_result(&row, "")?; + // After the upsert has committed, so a prune also sees rows that concurrent runs inserted. + if model.run_count == 1 { + prune_unstarred_over_cap(conn, run.org_id, run.user_email, run.source, &model.id).await?; + } Ok(model) } -/// Lists one user's entries in one org, newest first. +/// Lists one user's entries in one org, most recently run first. pub async fn list( org_id: &str, user_email: &str, - starred: Option, - q: Option<&str>, + filter: &ListFilter, limit: u64, offset: u64, ) -> Result, errors::Error> { let client = get_orm_client_ro().await; - list_with(client, org_id, user_email, starred, q, limit, offset).await + list_with(client, org_id, user_email, filter, limit, offset).await } pub async fn list_with( conn: &C, org_id: &str, user_email: &str, - starred: Option, - q: Option<&str>, + filter: &ListFilter, limit: u64, offset: u64, ) -> Result, errors::Error> { - let mut select = Entity::find() - .filter(Column::OrgId.eq(org_id)) - .filter(Column::UserEmail.eq(user_email)); - if let Some(starred) = starred { - select = select.filter(Column::Starred.eq(starred)); - } - if let Some(q) = q.filter(|q| !q.is_empty()) { - select = select.filter(query_matches(q)); - } - Ok(select - .order_by_desc(Column::CreatedAt) - .order_by_desc(Column::Id) + Ok(list_query(org_id, user_email, filter) .limit(limit) .offset(offset) .all(conn) .await?) } -/// Deletes the user's unstarred rows from the `MAX_UNSTARRED_PER_USER + 1`-th newest onward. -async fn prune_unstarred_over_cap( +/// How many entries `list` would return with no limit. +pub async fn count( + org_id: &str, + user_email: &str, + filter: &ListFilter, +) -> Result { + let client = get_orm_client_ro().await; + count_with(client, org_id, user_email, filter).await +} + +pub async fn count_with( conn: &C, org_id: &str, user_email: &str, -) -> Result<(), errors::Error> { - let unstarred = Condition::all() - .add(Column::OrgId.eq(org_id)) - .add(Column::UserEmail.eq(user_email)) - .add(Column::Starred.eq(false)); - let Some((created_at, id)) = Entity::find() - .filter(unstarred.clone()) - .select_only() - .column(Column::CreatedAt) - .column(Column::Id) - .order_by_desc(Column::CreatedAt) - .order_by_desc(Column::Id) - .offset(MAX_UNSTARRED_PER_USER) - .into_tuple::<(i64, String)>() - .one(conn) - .await? - else { - return Ok(()); - }; - let at_or_before_boundary = Condition::any().add(Column::CreatedAt.lt(created_at)).add( - Condition::all() - .add(Column::CreatedAt.eq(created_at)) - .add(Column::Id.lte(id)), - ); - Entity::delete_many() - .filter(unstarred) - .filter(at_or_before_boundary) - .exec(conn) - .await?; - Ok(()) -} - -/// Case-insensitive literal substring match: `%`, `_` and `\` match only themselves. -fn query_matches(q: &str) -> SimpleExpr { - let mut pattern = String::with_capacity(q.len() + 2); - pattern.push('%'); - for c in q.to_lowercase().chars() { - if matches!(c, '\\' | '%' | '_') { - pattern.push('\\'); - } - pattern.push(c); - } - pattern.push('%'); - Expr::expr(Func::lower(Expr::col(Column::Query))).like(LikeExpr::new(pattern).escape('\\')) + filter: &ListFilter, +) -> Result { + Ok(Entity::find() + .filter(Column::OrgId.eq(org_id)) + .filter(Column::UserEmail.eq(user_email)) + .filter(list_condition(filter)) + .count(conn) + .await?) } /// Stars or unstars one of the user's entries; starring is refused at `MAX_STARRED_PER_USER`. @@ -220,29 +194,35 @@ pub async fn set_starred( set_starred_with(client, org_id, user_email, id, starred).await } -pub async fn set_starred_with( +pub async fn set_starred_with( conn: &C, org_id: &str, user_email: &str, id: &str, starred: bool, ) -> Result { - // MySQL rows_affected counts changed rows, so a re-star reports 0; "not found" needs a lookup. + let txn = conn.begin().await?; + let users_rows = Condition::all() + .add(Column::OrgId.eq(org_id)) + .add(Column::UserEmail.eq(user_email)); + // Only a star can pass the cap, so an unstar takes no lock. + if starred && let Some(lock) = star_lock(txn.get_database_backend(), org_id, user_email) { + txn.execute(lock).await?; + } + // MySQL rows_affected counts changed rows, so a re-star reports 0; the response needs the row. let Some(entry) = Entity::find() - .filter(Column::OrgId.eq(org_id)) - .filter(Column::UserEmail.eq(user_email)) + .filter(users_rows.clone()) .filter(Column::Id.eq(id)) - .one(conn) + .one(&txn) .await? else { return Ok(SetStarred::NotFound); }; if starred && !entry.starred { let starred_count = Entity::find() - .filter(Column::OrgId.eq(org_id)) - .filter(Column::UserEmail.eq(user_email)) + .filter(users_rows) .filter(Column::Starred.eq(true)) - .count(conn) + .count(&txn) .await?; if starred_count >= MAX_STARRED_PER_USER { return Ok(SetStarred::StarredCapReached); @@ -251,9 +231,10 @@ pub async fn set_starred_with( Entity::update_many() .col_expr(Column::Starred, Expr::value(starred)) .filter(Column::Id.eq(id)) - .exec(conn) + .exec(&txn) .await?; - Ok(SetStarred::Updated(Model { starred, ..entry })) + txn.commit().await?; + Ok(SetStarred::Updated(Box::new(Model { starred, ..entry }))) } /// Deletes one of the user's entries in the org; false when it is not theirs or does not exist. @@ -277,7 +258,37 @@ pub async fn delete_with( Ok(res.rows_affected > 0) } -/// Deletes unstarred entries older than `cutoff_us` across every org and user; starred are kept. +/// Deletes the user's entries in the org for `sources` (all when empty); starred only on request. +pub async fn clear( + org_id: &str, + user_email: &str, + sources: &[String], + include_starred: bool, +) -> Result { + let client = get_orm_client_rw().await; + clear_with(client, org_id, user_email, sources, include_starred).await +} + +pub async fn clear_with( + conn: &C, + org_id: &str, + user_email: &str, + sources: &[String], + include_starred: bool, +) -> Result { + let mut delete = Entity::delete_many() + .filter(Column::OrgId.eq(org_id)) + .filter(Column::UserEmail.eq(user_email)); + if !sources.is_empty() { + delete = delete.filter(Column::Source.is_in(sources.iter().cloned())); + } + if !include_starred { + delete = delete.filter(Column::Starred.eq(false)); + } + Ok(delete.exec(conn).await?.rows_affected) +} + +/// Deletes unstarred entries first and last run before `cutoff_us` across every org and user. pub async fn delete_unstarred_before(cutoff_us: i64) -> Result { let client = get_orm_client_rw().await; delete_unstarred_before_with(client, cutoff_us).await @@ -289,6 +300,8 @@ pub async fn delete_unstarred_before_with( ) -> Result { let res = Entity::delete_many() .filter(Column::Starred.eq(false)) + .filter(Column::UpdatedAt.lt(cutoff_us)) + // An older node in a rolling upgrade writes rows with updated_at = 0. .filter(Column::CreatedAt.lt(cutoff_us)) .exec(conn) .await?; @@ -345,151 +358,708 @@ pub async fn delete_by_email_with( Ok(res.rows_affected) } +/// Splits the stored `,a,b,` form back into stream names. +pub fn decode_streams(streams: &str) -> Vec { + streams + .split(',') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +/// Creates the table as the migrations leave it; for tests, since the migrator replays everything. +#[cfg(any(test, feature = "test-utils"))] +pub async fn create_table_for_tests( + db: &sea_orm::DatabaseConnection, +) -> Result<(), sea_orm::DbErr> { + super::migration::create_query_history_table(db).await +} + +/// Comma-wrapped so `LIKE '%,name,%'` matches whole stream names only. +fn encode_streams(streams: &[String]) -> String { + if streams.is_empty() { + return String::new(); + } + format!(",{},", streams.join(",")) +} + +fn query_key_hash(query_key: Option<&str>, query: &str) -> String { + sha256::digest(query_key.filter(|k| !k.is_empty()).unwrap_or(query)) +} + +fn list_condition(filter: &ListFilter) -> Condition { + let mut cond = Condition::all(); + if !filter.sources.is_empty() { + cond = cond.add(Column::Source.is_in(filter.sources.iter().cloned())); + } + if let Some(status) = &filter.status { + cond = cond.add(Column::Status.eq(status.as_str())); + } + if let Some(stream) = filter.stream.as_deref().filter(|s| !s.is_empty()) { + let pattern = format!("%,{},%", escape_like(stream)); + cond = cond.add(Expr::col(Column::Streams).like(LikeExpr::new(pattern).escape('\\'))); + } + if let Some(q) = filter.q.as_deref().filter(|q| !q.is_empty()) { + cond = cond.add( + Condition::any() + .add(contains_lowered(Column::Query, q)) + .add(contains_lowered(Column::Streams, q)), + ); + } + if let Some(starred) = filter.starred { + cond = cond.add(Column::Starred.eq(starred)); + } + if let Some(from) = filter.ran_from { + cond = cond.add(Column::UpdatedAt.gte(from)); + } + if let Some(to) = filter.ran_to { + cond = cond.add(Column::UpdatedAt.lte(to)); + } + cond +} + +/// Newest first; the id breaks ties so pages never overlap. +fn list_query(org_id: &str, user_email: &str, filter: &ListFilter) -> sea_orm::Select { + Entity::find() + .filter(Column::OrgId.eq(org_id)) + .filter(Column::UserEmail.eq(user_email)) + .filter(list_condition(filter)) + .order_by_desc(Column::UpdatedAt) + .order_by_desc(Column::Id) +} + +/// Keeps `kept_id` and the newest other unstarred rows of `source`, up to the per-source cap. +async fn prune_unstarred_over_cap( + conn: &C, + org_id: &str, + user_email: &str, + source: &str, + kept_id: &str, +) -> Result<(), errors::Error> { + // The run just recorded survives a timestamp tie or a clock that stepped back. + let unstarred = Condition::all() + .add(Column::OrgId.eq(org_id)) + .add(Column::UserEmail.eq(user_email)) + .add(Column::Source.eq(source)) + .add(Column::Starred.eq(false)) + .add(Column::Id.ne(kept_id)); + let Some((ran_at, id)) = Entity::find() + .filter(unstarred.clone()) + .select_only() + .column(Column::UpdatedAt) + .column(Column::Id) + .order_by_desc(Column::UpdatedAt) + .order_by_desc(Column::Id) + .offset(MAX_UNSTARRED_PER_SOURCE - 1) + .into_tuple::<(i64, String)>() + .one(conn) + .await? + else { + return Ok(()); + }; + let at_or_before_boundary = Condition::any().add(Column::UpdatedAt.lt(ran_at)).add( + Condition::all() + .add(Column::UpdatedAt.eq(ran_at)) + .add(Column::Id.lte(id)), + ); + Entity::delete_many() + .filter(unstarred) + .filter(at_or_before_boundary) + .exec(conn) + .await?; + Ok(()) +} + +/// Serializes one user's stars in an org, so the starred count below cannot go stale. +fn star_lock(backend: DbBackend, org_id: &str, user_email: &str) -> Option { + match backend { + DbBackend::Postgres => Some(Statement::from_sql_and_values( + backend, + "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", + [format!("query_history_star/{org_id}/{user_email}").into()], + )), + // MySQL has no transaction-scoped named lock, so it row-locks the user's rows instead. + DbBackend::MySql => Some(Statement::from_sql_and_values( + backend, + "SELECT COUNT(*) FROM query_history WHERE org_id = ? AND user_email = ? FOR UPDATE", + [org_id.into(), user_email.into()], + )), + // SQLite serializes every write transaction already. + DbBackend::Sqlite => None, + } +} + +/// Case-insensitive literal substring match: `%`, `_` and `\` match only themselves. +fn contains_lowered(column: Column, q: &str) -> SimpleExpr { + let pattern = format!("%{}%", escape_like(&q.to_lowercase())); + Expr::expr(Func::lower(Expr::col(column))).like(LikeExpr::new(pattern).escape('\\')) +} + +fn escape_like(text: &str) -> String { + let mut escaped = String::with_capacity(text.len()); + for c in text.chars() { + if matches!(c, '\\' | '%' | '_') { + escaped.push('\\'); + } + escaped.push(c); + } + escaped +} + #[cfg(test)] mod tests { - use sea_orm::{Database, DatabaseConnection, PaginatorTrait, Schema}; + use sea_orm::{Database, DatabaseConnection, PaginatorTrait}; use serde_json::json; use super::*; const A: &str = "a@x.com"; const B: &str = "b@x.com"; + const DAY_US: i64 = 86_400 * 1_000_000; async fn db() -> DatabaseConnection { let db = Database::connect("sqlite::memory:").await.unwrap(); - let backend = db.get_database_backend(); - let stmt = Schema::new(backend).create_table_from_entity(Entity); - db.execute(backend.build(&stmt)).await.unwrap(); + create_table_for_tests(&db).await.unwrap(); db } + fn run<'a>(user: &'a str, source: &'a str, query: &'a str, now_us: i64) -> NewRun<'a> { + NewRun { + org_id: "default", + user_email: user, + source, + query, + query_key: None, + streams: &[], + status: STATUS_OK, + context: json!({}), + now_us, + } + } + + async fn rec(db: &DatabaseConnection, run: NewRun<'_>) -> Model { + record_with(db, run).await.unwrap() + } + fn updated(outcome: SetStarred) -> Model { match outcome { - SetStarred::Updated(m) => m, + SetStarred::Updated(m) => *m, _ => panic!("expected the entry to be updated"), } } - async fn all(db: &DatabaseConnection, user: &str) -> Vec { - list_with(db, "default", user, None, None, 10_000, 0) + async fn listed(db: &DatabaseConnection, user: &str, filter: ListFilter) -> Vec { + list_with(db, "default", user, &filter, 10_000, 0) .await .unwrap() } + async fn all(db: &DatabaseConnection, user: &str) -> Vec { + listed(db, user, ListFilter::default()).await + } + + fn queries(rows: Vec) -> Vec { + rows.into_iter().map(|r| r.query).collect() + } + #[tokio::test] - async fn test_same_query_as_latest_updates_time_and_context() { + async fn test_same_key_merges_and_keeps_first_run_and_star() { let db = db().await; - let first = record_with(&db, "default", A, "up", json!({"chart": "line"}), 1) - .await - .unwrap(); - let again = record_with(&db, "default", A, "up", json!({"chart": "bar"}), 2) + let streams = vec!["k8s".to_string()]; + let first = rec( + &db, + NewRun { + query_key: Some("k8s|sql|up|"), + streams: &streams, + context: json!({"chart": "line"}), + ..run(A, "logs", "up", 1) + }, + ) + .await; + assert_eq!( + (first.run_count, first.created_at, first.updated_at), + (1, 1, 1) + ); + set_starred_with(&db, "default", A, &first.id, true) .await .unwrap(); + + let again = rec( + &db, + NewRun { + query_key: Some("k8s|sql|up|"), + status: STATUS_ERROR, + context: json!({"error": "boom"}), + ..run(A, "logs", "up", 5) + }, + ) + .await; assert_eq!(again.id, first.id); - let rows = all(&db, A).await; - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].created_at, 2); - assert_eq!(rows[0].context, json!({"chart": "bar"})); - - record_with(&db, "default", A, "rate(x[5m])", json!({}), 3) - .await - .unwrap(); - record_with(&db, "default", A, "up", json!({}), 4) - .await - .unwrap(); - assert_eq!(all(&db, A).await.len(), 3, "only the latest entry dedupes"); - } - - #[tokio::test] - async fn test_dedupe_is_per_user() { - let db = db().await; - record_with(&db, "default", A, "up", json!({}), 1) - .await - .unwrap(); - record_with(&db, "default", B, "up", json!({}), 2) - .await - .unwrap(); + assert_eq!( + (again.run_count, again.created_at, again.updated_at), + (2, 1, 5) + ); + assert!(again.starred, "a merge keeps the star"); + assert_eq!(again.status, STATUS_ERROR); + assert_eq!(again.context, json!({"error": "boom"})); + assert_eq!(again.streams, ",k8s,", "the key already holds the streams"); assert_eq!(all(&db, A).await.len(), 1); - assert_eq!(all(&db, B).await.len(), 1); } #[tokio::test] - async fn test_cap_prunes_oldest_unstarred_and_keeps_starred() { + async fn test_the_same_search_on_two_days_is_one_row() { let db = db().await; - let starred = record_with(&db, "default", A, "starred", json!({}), 0) - .await - .unwrap(); + let key = Some("k8s|sql|up|"); + let monday = rec( + &db, + NewRun { + query_key: key, + ..run(A, "logs", "up", DAY_US) + }, + ) + .await; + let later = rec( + &db, + NewRun { + query_key: key, + ..run(A, "logs", "up", 40 * DAY_US) + }, + ) + .await; + assert_eq!(later.id, monday.id); + assert_eq!( + (later.run_count, later.created_at, later.updated_at), + (2, DAY_US, 40 * DAY_US) + ); + assert_eq!(all(&db, A).await.len(), 1); + } + + #[tokio::test] + async fn test_a_merge_sets_only_run_count_updated_at_context_and_status() { + let db = db().await; + let k8s = vec!["k8s".to_string()]; + let other = vec!["other".to_string()]; + let first = rec( + &db, + NewRun { + query_key: Some("k8s|sql|q|"), + streams: &k8s, + ..run(A, "logs", "SELECT 1", 1) + }, + ) + .await; + let merged = rec( + &db, + NewRun { + query_key: Some("k8s|sql|q|"), + streams: &other, + status: STATUS_ERROR, + context: json!({"error": "boom"}), + ..run(A, "logs", "SELECT 1 -- edited", 2) + }, + ) + .await; + assert_eq!(merged.id, first.id); + assert_eq!( + (merged.run_count, merged.created_at, merged.updated_at), + (2, 1, 2) + ); + assert_eq!( + (merged.status.as_str(), &merged.context), + (STATUS_ERROR, &json!({"error": "boom"})) + ); + assert_eq!( + (merged.query.as_str(), merged.streams.as_str()), + ("SELECT 1", ",k8s,"), + "query and streams stay as inserted" + ); + } + + #[tokio::test] + async fn test_a_merge_on_a_later_day_leaves_every_other_column_as_first_inserted() { + let db = db().await; + let k8s = vec!["k8s".to_string()]; + let first = rec( + &db, + NewRun { + query_key: Some("k8s|sql|q|"), + streams: &k8s, + context: json!({"run": 1}), + ..run(A, "logs", "q", DAY_US) + }, + ) + .await; + let starred = updated( + set_starred_with(&db, "default", A, &first.id, true) + .await + .unwrap(), + ); + let merged = rec( + &db, + NewRun { + query_key: Some("k8s|sql|q|"), + streams: &[], + status: STATUS_ERROR, + context: json!({"run": 2}), + ..run(A, "logs", "q edited", 3 * DAY_US) + }, + ) + .await; + assert_eq!( + merged, + Model { + run_count: 2, + updated_at: 3 * DAY_US, + context: json!({"run": 2}), + status: STATUS_ERROR.to_string(), + ..starred + } + ); + assert_eq!(all(&db, A).await, vec![merged]); + } + + #[tokio::test] + async fn test_merge_is_per_user_per_source_and_per_key() { + let db = db().await; + rec(&db, run(A, "logs", "up", 1)).await; + rec(&db, run(B, "logs", "up", 2)).await; + rec(&db, run(A, "traces", "up", 3)).await; + rec( + &db, + NewRun { + query_key: Some("up|2026-10-28"), + ..run(A, "logs", "up", 4) + }, + ) + .await; + assert_eq!(all(&db, A).await.len(), 3); + assert_eq!(all(&db, B).await.len(), 1); + let merged = rec(&db, run(A, "logs", "up", 5)).await; + assert_eq!(merged.run_count, 2, "no key falls back to the query text"); + } + + #[tokio::test] + async fn test_new_key_prunes_over_the_per_source_cap_and_keeps_starred() { + let db = db().await; + let starred = rec(&db, run(A, "logs", "starred", 0)).await; set_starred_with(&db, "default", A, &starred.id, true) .await .unwrap(); - for i in 1..=MAX_UNSTARRED_PER_USER as i64 { - record_with(&db, "default", A, &format!("q{i}"), json!({}), i) - .await - .unwrap(); + for i in 1..=MAX_UNSTARRED_PER_SOURCE as i64 { + rec(&db, run(A, "logs", &format!("q{i}"), i)).await; } - record_with(&db, "default", B, "other user", json!({}), 1) - .await - .unwrap(); - assert_eq!(all(&db, A).await.len(), 1001); + rec(&db, run(A, "traces", "other source", 0)).await; + rec(&db, run(B, "logs", "other user", 0)).await; + let logs = |rows: Vec| rows.into_iter().filter(|r| r.source == "logs").count(); + assert_eq!(logs(all(&db, A).await), 501); - record_with(&db, "default", A, "q1001", json!({}), 1001) - .await - .unwrap(); + rec(&db, run(A, "logs", "q1", 1_000)).await; + assert_eq!(logs(all(&db, A).await), 501, "a merge never prunes"); + + rec(&db, run(A, "logs", "q501", 1_001)).await; let rows = all(&db, A).await; - assert_eq!(rows.len(), 1001); - assert!(rows.iter().all(|r| r.query != "q1"), "oldest is pruned"); - assert!(rows.iter().any(|r| r.query == "q2")); + assert_eq!(logs(rows.clone()), 501); + assert!(rows.iter().all(|r| r.query != "q2"), "oldest is pruned"); + assert!( + rows.iter().any(|r| r.query == "q1"), + "the merge refreshed q1" + ); assert!(rows.iter().any(|r| r.id == starred.id && r.starred)); + assert!(rows.iter().any(|r| r.source == "traces")); assert_eq!(all(&db, B).await.len(), 1); } #[tokio::test] async fn test_cap_holds_when_timestamps_tie() { let db = db().await; - for i in 0..=MAX_UNSTARRED_PER_USER { - record_with(&db, "default", A, &format!("q{i}"), json!({}), 7) - .await - .unwrap(); + for i in 0..=MAX_UNSTARRED_PER_SOURCE { + rec(&db, run(A, "logs", &format!("q{i}"), 7)).await; } - assert_eq!(all(&db, A).await.len() as u64, MAX_UNSTARRED_PER_USER); + assert_eq!(all(&db, A).await.len() as u64, MAX_UNSTARRED_PER_SOURCE); } #[tokio::test] - async fn test_list_filters_newest_first() { + async fn test_reaper_uses_updated_at_and_keeps_starred() { let db = db().await; - let up = record_with(&db, "default", A, "up", json!({}), 1) + let now = 100 * DAY_US; + let cutoff = now - 14 * DAY_US; + let old = rec(&db, run(A, "logs", "old", now - 15 * DAY_US)).await; + let old_starred = rec(&db, run(A, "logs", "old starred", now - 15 * DAY_US)).await; + set_starred_with(&db, "default", A, &old_starred.id, true) .await .unwrap(); - record_with(&db, "default", A, "rate(http_total[5m])", json!({}), 2) - .await - .unwrap(); - record_with(&db, "default", A, "sum(http_total)", json!({}), 3) - .await - .unwrap(); - record_with(&db, "other", A, "http_total", json!({}), 4) + let rerun = rec(&db, run(A, "logs", "rerun", now - 20 * DAY_US)).await; + rec(&db, run(A, "logs", "rerun", now)).await; + + assert_eq!(delete_unstarred_before_with(&db, cutoff).await.unwrap(), 1); + let ids: Vec = all(&db, A).await.into_iter().map(|r| r.id).collect(); + assert!(!ids.contains(&old.id)); + assert!(ids.contains(&old_starred.id)); + assert!(ids.contains(&rerun.id), "a recent run keeps an old row"); + } + + async fn row_at(db: &DatabaseConnection, id: &str, created_at: i64, updated_at: i64) { + db.execute_unprepared(&format!( + "INSERT INTO query_history (id, org_id, user_email, query, context, created_at, \ + updated_at, source) VALUES ('{id}', 'default', '{A}', 'q {id}', '{{}}', {created_at}, \ + {updated_at}, 'logs')" + )) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_the_reaper_needs_both_runs_before_the_cutoff() { + let db = db().await; + row_at(&db, "stale", 50, 60).await; + row_at(&db, "fresh", 50, 500).await; + row_at(&db, "old-node", 500, 0).await; + assert_eq!(delete_unstarred_before_with(&db, 100).await.unwrap(), 1); + let mut ids: Vec = all(&db, A).await.into_iter().map(|r| r.id).collect(); + ids.sort(); + assert_eq!(ids, vec!["fresh", "old-node"]); + } + + #[tokio::test] + async fn test_order_window_and_prune_read_updated_at_alone() { + let db = db().await; + for i in 1..=MAX_UNSTARRED_PER_SOURCE as i64 { + row_at(&db, &format!("r{i:04}"), 1_000_000 - i, i * 10).await; + } + let rows = all(&db, A).await; + assert_eq!( + rows.iter() + .take(2) + .map(|r| r.id.as_str()) + .collect::>(), + vec!["r0500", "r0499"], + "newest updated_at first, whatever created_at says" + ); + let window = ListFilter { + ran_from: Some(4_980), + ran_to: Some(4_990), + ..Default::default() + }; + assert_eq!( + listed(&db, A, window) + .await + .iter() + .map(|r| r.id.as_str()) + .collect::>(), + vec!["r0499", "r0498"] + ); + rec(&db, run(A, "logs", "new", 1)).await; + let ids: Vec = all(&db, A).await.into_iter().map(|r| r.id).collect(); + assert_eq!(ids.len() as u64, MAX_UNSTARRED_PER_SOURCE); + assert!( + !ids.contains(&"r0001".to_string()), + "lowest updated_at is pruned" + ); + assert!(ids.contains(&"r0002".to_string())); + } + + #[tokio::test] + async fn test_count_matches_the_list_filters() { + let db = db().await; + let starred = rec(&db, run(A, "logs", "a", 1)).await; + set_starred_with(&db, "default", A, &starred.id, true) .await .unwrap(); + rec(&db, run(A, "logs", "b", 2)).await; + rec(&db, run(A, "traces", "c", 3)).await; + rec(&db, run(B, "logs", "d", 4)).await; + let count = |sources: &[&str], starred: Option| ListFilter { + sources: sources.iter().map(|s| s.to_string()).collect(), + starred, + ..Default::default() + }; + assert_eq!( + count_with(&db, "default", A, &count(&[], None)) + .await + .unwrap(), + 3 + ); + assert_eq!( + count_with(&db, "default", A, &count(&["logs"], None)) + .await + .unwrap(), + 2 + ); + assert_eq!( + count_with(&db, "default", A, &count(&["logs"], Some(false))) + .await + .unwrap(), + 1 + ); + assert_eq!( + count_with(&db, "default", A, &count(&[], Some(true))) + .await + .unwrap(), + 1 + ); + } + + #[tokio::test] + async fn test_a_new_row_survives_the_cap_when_others_ran_later() { + let db = db().await; + for i in 0..MAX_UNSTARRED_PER_SOURCE as i64 { + rec(&db, run(A, "logs", &format!("q{i}"), 1_000 + i)).await; + } + // The clock stepped back, or every row ties: the new run must still be kept. + let new = rec(&db, run(A, "logs", "after the step back", 5)).await; + let rows = all(&db, A).await; + assert_eq!(rows.len() as u64, MAX_UNSTARRED_PER_SOURCE); + assert!(rows.iter().any(|r| r.id == new.id)); + assert!( + rows.iter().all(|r| r.query != "q0"), + "the oldest other row is pruned" + ); + + let tie = rec(&db, run(A, "logs", "tie", 1_001)).await; + let rows = all(&db, A).await; + assert_eq!(rows.len() as u64, MAX_UNSTARRED_PER_SOURCE); + assert!(rows.iter().any(|r| r.id == tie.id)); + } + + #[tokio::test] + async fn test_list_order_is_served_by_the_updated_at_indexes_on_sqlite() { + let db = db().await; + for i in 0..20 { + rec(&db, run(A, "logs", &format!("q{i}"), i)).await; + } + let filters = [ + ListFilter::default(), + ListFilter { + sources: vec!["logs".to_string()], + ..Default::default() + }, + ]; + for filter in filters { + let sql = list_query("default", A, &filter) + .limit(50) + .build(sea_orm::DbBackend::Sqlite) + .to_string(); + let plan: Vec = db + .query_all(sea_orm::Statement::from_string( + sea_orm::DbBackend::Sqlite, + format!("EXPLAIN QUERY PLAN {sql}"), + )) + .await + .unwrap() + .iter() + .map(|r| r.try_get::("", "detail").unwrap()) + .collect(); + let plan = plan.join(" | "); + assert!(plan.contains("updated_at_idx"), "{plan}"); + assert!(!plan.contains("TEMP B-TREE FOR ORDER BY"), "{plan}"); + } + } + + #[tokio::test] + async fn test_concurrent_new_rows_keep_the_per_source_cap() { + let db = db().await; + for i in 0..MAX_UNSTARRED_PER_SOURCE as i64 { + rec(&db, run(A, "logs", &format!("q{i}"), i)).await; + } + let queries: Vec = (0..8).map(|i| format!("new{i}")).collect(); + let results = futures::future::join_all( + queries + .iter() + .enumerate() + .map(|(i, q)| record_with(&db, run(A, "logs", q, 10_000 + i as i64))), + ) + .await; + assert!(results.iter().all(Result::is_ok)); + assert_eq!(all(&db, A).await.len() as u64, MAX_UNSTARRED_PER_SOURCE); + } + + #[tokio::test] + async fn test_list_orders_by_last_run_and_filters() { + let db = db().await; + let k8s = vec!["k8s_logs".to_string(), "default".to_string()]; + let app = vec!["app_logs".to_string()]; + let up = rec( + &db, + NewRun { + streams: &k8s, + ..run(A, "logs", "level = 'error'", 1) + }, + ) + .await; + rec( + &db, + NewRun { + streams: &app, + status: STATUS_ERROR, + ..run(A, "logs", "SELECT * FROM app", 2) + }, + ) + .await; + rec(&db, run(A, "traces", "service = 'cart'", 3)).await; + rec(&db, run(A, "metrics", "rate(http_total[5m])", 4)).await; + rec(&db, run(A, "logs", "level = 'error'", 5)).await; set_starred_with(&db, "default", A, &up.id, true) .await .unwrap(); - let queries = |rows: Vec| rows.into_iter().map(|r| r.query).collect::>(); + let f = |build: fn(&mut ListFilter)| { + let mut filter = ListFilter::default(); + build(&mut filter); + filter + }; assert_eq!( queries(all(&db, A).await), - vec!["sum(http_total)", "rate(http_total[5m])", "up"] + vec![ + "level = 'error'", + "rate(http_total[5m])", + "service = 'cart'", + "SELECT * FROM app" + ] ); - let starred = list_with(&db, "default", A, Some(true), None, 50, 0) - .await - .unwrap(); - assert_eq!(queries(starred), vec!["up"]); - let matched = list_with(&db, "default", A, None, Some("http_total"), 50, 0) - .await - .unwrap(); + let by_source = f(|f| f.sources = vec!["logs".into(), "traces".into()]); + assert_eq!(queries(listed(&db, A, by_source).await).len(), 3); + let failed = f(|f| f.status = Some(STATUS_ERROR.into())); assert_eq!( - queries(matched), - vec!["sum(http_total)", "rate(http_total[5m])"] + queries(listed(&db, A, failed).await), + vec!["SELECT * FROM app"] ); - let page = list_with(&db, "default", A, None, None, 1, 1) + let stream = f(|f| f.stream = Some("default".into())); + assert_eq!( + queries(listed(&db, A, stream).await), + vec!["level = 'error'"], + "a merge keeps the streams" + ); + let stream = f(|f| f.stream = Some("app_logs".into())); + assert_eq!( + queries(listed(&db, A, stream).await), + vec!["SELECT * FROM app"] + ); + let partial = f(|f| f.stream = Some("app".into())); + assert!(listed(&db, A, partial).await.is_empty(), "stream is exact"); + let q_stream = f(|f| f.q = Some("APP_LO".into())); + assert_eq!( + queries(listed(&db, A, q_stream).await), + vec!["SELECT * FROM app"] + ); + let q_query = f(|f| f.q = Some("HTTP".into())); + assert_eq!( + queries(listed(&db, A, q_query).await), + vec!["rate(http_total[5m])"] + ); + let starred = f(|f| f.starred = Some(true)); + assert_eq!( + queries(listed(&db, A, starred).await), + vec!["level = 'error'"] + ); + let window = f(|f| { + f.ran_from = Some(2); + f.ran_to = Some(3); + }); + assert_eq!( + queries(listed(&db, A, window).await), + vec!["service = 'cart'", "SELECT * FROM app"] + ); + let page = list_with(&db, "default", A, &ListFilter::default(), 1, 1) .await .unwrap(); assert_eq!(queries(page), vec!["rate(http_total[5m])"]); @@ -509,19 +1079,16 @@ mod tests { .into_iter() .enumerate() { - record_with(&db, "default", A, query, json!({}), i as i64) - .await - .unwrap(); + rec(&db, run(A, "metrics", query, i as i64)).await; } let search = |q: &'static str| { let db = &db; async move { - let mut rows: Vec = list_with(db, "default", A, None, Some(q), 50, 0) - .await - .unwrap() - .into_iter() - .map(|r| r.query) - .collect(); + let filter = ListFilter { + q: Some(q.to_string()), + ..Default::default() + }; + let mut rows = queries(listed(db, A, filter).await); rows.sort(); rows } @@ -535,9 +1102,9 @@ mod tests { // Postgres LIKE is case-sensitive, so the filter must lower both sides. #[test] fn test_search_sql_lowers_and_escapes_on_postgres() { - use sea_orm::{DbBackend, QueryTrait}; + use sea_orm::DbBackend; let sql = Entity::find() - .filter(query_matches("50%_X")) + .filter(contains_lowered(Column::Query, "50%_X")) .build(DbBackend::Postgres) .to_string(); assert!( @@ -546,12 +1113,95 @@ mod tests { ); } + #[test] + fn test_star_lock_is_narrow_on_each_backend() { + let pg = star_lock(DbBackend::Postgres, "default", A) + .unwrap() + .to_string(); + assert!(pg.contains("pg_advisory_xact_lock"), "{pg}"); + assert!( + !pg.contains("FOR UPDATE"), + "no rows are locked or read: {pg}" + ); + let mysql = star_lock(DbBackend::MySql, "default", A) + .unwrap() + .to_string(); + assert!( + mysql.starts_with("SELECT COUNT(*)") && mysql.ends_with("FOR UPDATE"), + "{mysql}" + ); + assert!(star_lock(DbBackend::Sqlite, "default", A).is_none()); + } + + #[test] + fn test_upsert_sql_targets_the_unique_key_on_postgres() { + use sea_orm::DbBackend; + let sql = Entity::insert(ActiveModel { + id: Set("x".into()), + ..Default::default() + }) + .on_conflict( + OnConflict::columns([Column::OrgId, Column::QueryKey]) + .value( + Column::RunCount, + Expr::col((Entity, Column::RunCount)).add(1), + ) + .to_owned(), + ) + .build(DbBackend::Postgres) + .to_string(); + assert!( + sql.contains(r#""run_count" = "query_history"."run_count" + 1"#), + "{sql}" + ); + } + + #[test] + fn test_streams_round_trip_through_the_comma_wrapped_form() { + let streams = vec!["a".to_string(), "b".to_string()]; + assert_eq!(encode_streams(&streams), ",a,b,"); + assert_eq!(decode_streams(",a,b,"), streams); + assert_eq!(encode_streams(&[]), ""); + assert!(decode_streams("").is_empty()); + } + + #[test] + fn test_query_key_is_a_sha256_of_the_key_or_the_query() { + assert_eq!(query_key_hash(Some("k"), "q"), sha256::digest("k")); + assert_eq!(query_key_hash(None, "q"), sha256::digest("q")); + assert_eq!(query_key_hash(Some(""), "q"), sha256::digest("q")); + assert_eq!(query_key_hash(None, "q").len(), 64); + } + + #[tokio::test] + async fn test_clear_scopes_by_source_and_keeps_starred_unless_asked() { + let db = db().await; + let star = rec(&db, run(A, "logs", "starred", 1)).await; + set_starred_with(&db, "default", A, &star.id, true) + .await + .unwrap(); + rec(&db, run(A, "logs", "a", 2)).await; + rec(&db, run(A, "traces", "b", 3)).await; + rec(&db, run(A, "metrics", "c", 4)).await; + rec(&db, run(B, "logs", "a", 5)).await; + + let logs = vec!["logs".to_string()]; + assert_eq!( + clear_with(&db, "default", A, &logs, false).await.unwrap(), + 1 + ); + assert_eq!(queries(all(&db, A).await), vec!["c", "b", "starred"]); + assert_eq!(clear_with(&db, "default", A, &[], false).await.unwrap(), 2); + assert_eq!(queries(all(&db, A).await), vec!["starred"]); + assert_eq!(clear_with(&db, "default", A, &[], true).await.unwrap(), 1); + assert!(all(&db, A).await.is_empty()); + assert_eq!(all(&db, B).await.len(), 1, "only the caller's rows"); + } + #[tokio::test] async fn test_by_id_operations_are_scoped_to_org_and_user() { let db = db().await; - let entry = record_with(&db, "default", A, "up", json!({}), 1) - .await - .unwrap(); + let entry = rec(&db, run(A, "metrics", "up", 1)).await; assert!(matches!( set_starred_with(&db, "default", B, &entry.id, true) @@ -583,9 +1233,7 @@ mod tests { #[tokio::test] async fn test_starring_twice_still_returns_the_entry() { let db = db().await; - let entry = record_with(&db, "default", A, "up", json!({}), 1) - .await - .unwrap(); + let entry = rec(&db, run(A, "metrics", "up", 1)).await; for _ in 0..2 { let starred = updated( set_starred_with(&db, "default", A, &entry.id, true) @@ -609,13 +1257,12 @@ mod tests { } #[tokio::test] - async fn test_starring_past_the_cap_is_refused_per_user() { + async fn test_starring_past_the_cap_is_refused_across_sources() { let db = db().await; let mut ids = Vec::new(); for i in 0..=MAX_STARRED_PER_USER as i64 { - let entry = record_with(&db, "default", A, &format!("q{i}"), json!({}), i) - .await - .unwrap(); + let source = SOURCES[i as usize % 3]; + let entry = rec(&db, run(A, source, &format!("q{i}"), i)).await; assert!(!entry.starred, "recording never stars"); ids.push(entry.id); } @@ -638,22 +1285,12 @@ mod tests { .starred, "re-starring an already starred entry at the cap still succeeds" ); - let other_user = record_with(&db, "default", B, "up", json!({}), 1) - .await - .unwrap(); + let other_user = rec(&db, run(B, "logs", "up", 1)).await; updated( set_starred_with(&db, "default", B, &other_user.id, true) .await .unwrap(), ); - let other_org = record_with(&db, "other", A, "up", json!({}), 1) - .await - .unwrap(); - updated( - set_starred_with(&db, "other", A, &other_org.id, true) - .await - .unwrap(), - ); updated( set_starred_with(&db, "default", A, &at_cap[0], false) @@ -672,68 +1309,167 @@ mod tests { } #[tokio::test] - async fn test_dedupe_keeps_the_starred_flag() { + async fn test_concurrent_stars_never_pass_the_cap() { let db = db().await; - let entry = record_with(&db, "default", A, "up", json!({}), 1) + let mut ids = Vec::new(); + for i in 0..MAX_STARRED_PER_USER as i64 + 5 { + ids.push(rec(&db, run(A, "logs", &format!("q{i}"), i)).await.id); + } + let (racing, below_cap) = ids.split_at(6); + for id in &below_cap[..MAX_STARRED_PER_USER as usize - 1] { + updated(set_starred_with(&db, "default", A, id, true).await.unwrap()); + } + let outcomes = futures::future::join_all( + racing + .iter() + .map(|id| set_starred_with(&db, "default", A, id, true)), + ) + .await; + let starred_now = outcomes + .into_iter() + .filter(|o| matches!(o, Ok(SetStarred::Updated(_)))) + .count(); + assert_eq!(starred_now, 1); + let starred = Entity::find() + .filter(Column::Starred.eq(true)) + .count(&db) .await .unwrap(); - updated( - set_starred_with(&db, "default", A, &entry.id, true) - .await - .unwrap(), - ); - let again = record_with(&db, "default", A, "up", json!({"chart": "bar"}), 2) - .await - .unwrap(); - assert_eq!(again.id, entry.id); - assert!(again.starred); - assert_eq!(again.org_id, "default"); - assert_eq!(again.user_email, A); - assert_eq!(again.query, "up"); + assert_eq!(starred, MAX_STARRED_PER_USER); } #[tokio::test] - async fn test_retention_deletes_only_old_unstarred_rows() { + async fn test_concurrent_first_stars_from_zero_stop_at_the_cap() { let db = db().await; - let day_us = 86_400 * 1_000_000; - let now = 100 * day_us; - let old = record_with(&db, "default", A, "old", json!({}), now - 15 * day_us) - .await - .unwrap(); - let old_starred = record_with( - &db, - "default", - A, - "old starred", - json!({}), - now - 15 * day_us, + let mut ids = Vec::new(); + for i in 0..MAX_STARRED_PER_USER as i64 + 10 { + ids.push(rec(&db, run(A, "logs", &format!("q{i}"), i)).await.id); + } + let outcomes = futures::future::join_all( + ids.iter() + .map(|id| set_starred_with(&db, "default", A, id, true)), ) - .await - .unwrap(); - set_starred_with(&db, "default", A, &old_starred.id, true) - .await - .unwrap(); - record_with(&db, "default", A, "fresh", json!({}), now) + .await; + let starred_now = outcomes + .iter() + .filter(|o| matches!(o, Ok(SetStarred::Updated(_)))) + .count(); + let refused = outcomes + .iter() + .filter(|o| matches!(o, Ok(SetStarred::StarredCapReached))) + .count(); + assert_eq!((starred_now, refused), (MAX_STARRED_PER_USER as usize, 10)); + let starred = Entity::find() + .filter(Column::Starred.eq(true)) + .count(&db) .await .unwrap(); + assert_eq!(starred, MAX_STARRED_PER_USER); + } - let deleted = delete_unstarred_before_with(&db, now - 14 * day_us) + /// One-connection pools on a throwaway CI Postgres schema, so calls truly race. + async fn postgres_pools( + n: usize, + ) -> Option<(DatabaseConnection, String, Vec)> { + 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_test_{}", config::ider::generate().to_ascii_lowercase()); + let admin = Database::connect(&dsn).await.unwrap(); + admin + .execute_unprepared(&format!(r#"CREATE SCHEMA "{schema}""#)) .await .unwrap(); - assert_eq!(deleted, 1); - let ids: Vec = all(&db, A).await.into_iter().map(|r| r.id).collect(); - assert!(!ids.contains(&old.id)); - assert!(ids.contains(&old_starred.id)); - assert_eq!(ids.len(), 2); + let mut pools = Vec::new(); + for _ in 0..n { + let mut options = sea_orm::ConnectOptions::new(dsn.clone()); + options + .max_connections(1) + .min_connections(1) + .set_schema_search_path(schema.clone()); + pools.push(Database::connect(options).await.unwrap()); + } + create_table_for_tests(&pools[0]).await.unwrap(); + Some((admin, schema, pools)) + } + + async fn drop_schema(admin: DatabaseConnection, schema: &str, pools: Vec) { + for pool in pools { + pool.close().await.unwrap(); + } + admin + .execute_unprepared(&format!(r#"DROP SCHEMA "{schema}" CASCADE"#)) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_postgres_concurrent_first_stars_stop_at_the_cap_when_ci_provides_it() { + let Some((admin, schema, pools)) = postgres_pools(8).await else { + return; + }; + let mut ids = Vec::new(); + for i in 0..MAX_STARRED_PER_USER as i64 + 16 { + ids.push(rec(&pools[0], run(A, "logs", &format!("q{i}"), i)).await.id); + } + let outcomes = futures::future::join_all( + ids.iter() + .enumerate() + .map(|(i, id)| set_starred_with(&pools[i % pools.len()], "default", A, id, true)), + ) + .await; + let starred_now = outcomes + .iter() + .filter(|o| matches!(o, Ok(SetStarred::Updated(_)))) + .count(); + assert_eq!(starred_now, MAX_STARRED_PER_USER as usize); + let starred = Entity::find() + .filter(Column::Starred.eq(true)) + .count(&pools[0]) + .await + .unwrap(); + assert_eq!(starred, MAX_STARRED_PER_USER); + drop_schema(admin, &schema, pools).await; + } + + #[tokio::test] + async fn test_postgres_concurrent_new_rows_keep_the_cap_when_ci_provides_it() { + let Some((admin, schema, pools)) = postgres_pools(8).await else { + return; + }; + for i in 0..MAX_UNSTARRED_PER_SOURCE as i64 { + rec(&pools[0], run(A, "logs", &format!("q{i}"), i)).await; + } + let queries: Vec = (0..16).map(|i| format!("new{i}")).collect(); + let results = futures::future::join_all(queries.iter().enumerate().map(|(i, q)| { + record_with( + &pools[i % pools.len()], + run(A, "logs", q, 10_000 + i as i64), + ) + })) + .await; + assert!(results.iter().all(Result::is_ok)); + assert_eq!( + all(&pools[0], A).await.len() as u64, + MAX_UNSTARRED_PER_SOURCE + ); + drop_schema(admin, &schema, pools).await; } #[tokio::test] async fn test_cleanup_by_org_user_and_email() { let db = db().await; for (org, user) in [("default", A), ("default", B), ("other", A), ("other", B)] { - record_with(&db, org, user, "up", json!({}), 1) - .await - .unwrap(); + rec( + &db, + NewRun { + org_id: org, + ..run(user, "logs", "up", 1) + }, + ) + .await; } assert_eq!(delete_by_user_with(&db, "default", A).await.unwrap(), 1); assert!(all(&db, A).await.is_empty()); diff --git a/tests/ui-testing/pages/metricsPages/metricsPage.js b/tests/ui-testing/pages/metricsPages/metricsPage.js index 1927111a6f..a4df477510 100644 --- a/tests/ui-testing/pages/metricsPages/metricsPage.js +++ b/tests/ui-testing/pages/metricsPages/metricsPage.js @@ -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'); } } \ No newline at end of file diff --git a/tests/ui-testing/playwright-tests/Metrics/metrics-query-history.spec.js b/tests/ui-testing/playwright-tests/Metrics/metrics-query-history.spec.js index 7b67185e11..d8ae345351 100644 --- a/tests/ui-testing/playwright-tests/Metrics/metrics-query-history.spec.js +++ b/tests/ui-testing/playwright-tests/Metrics/metrics-query-history.spec.js @@ -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 }); diff --git a/web/src/components/CodeQueryEditor.spec.ts b/web/src/components/CodeQueryEditor.spec.ts index 7d8ba53d96..e982a62ec4 100644 --- a/web/src/components/CodeQueryEditor.spec.ts +++ b/web/src/components/CodeQueryEditor.spec.ts @@ -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 }); diff --git a/web/src/components/CodeQueryEditor.vue b/web/src/components/CodeQueryEditor.vue index eea90e96c3..64c59d18d0 100644 --- a/web/src/components/CodeQueryEditor.vue +++ b/web/src/components/CodeQueryEditor.vue @@ -102,6 +102,7 @@ const loadMonaco = (): Promise => { }; 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(); const registeredLanguages = new Set(); 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, + 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; }, }); diff --git a/web/src/components/QueryEditor.vue b/web/src/components/QueryEditor.vue index 83c5274522..4794b88a55 100644 --- a/web/src/components/QueryEditor.vue +++ b/web/src/components/QueryEditor.vue @@ -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(), { 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": []; diff --git a/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts b/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts index eae6bc12ce..e5690c2526 100644 --- a/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts +++ b/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts @@ -99,6 +99,7 @@ const mockChartData = ref(undefined as any); const mockShowLegendsDialog = ref(false); const mockNoticeProps = ref>({}); 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 ", 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: "" }]); + }); + }); }); diff --git a/web/src/components/dashboards/PanelEditor/PanelEditor.vue b/web/src/components/dashboards/PanelEditor/PanelEditor.vue index 8f2fba9761..9d26670ced 100644 --- a/web/src/components/dashboards/PanelEditor/PanelEditor.vue +++ b/web/src/components/dashboards/PanelEditor/PanelEditor.vue @@ -325,7 +325,7 @@ along with this program. If not, see . 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 . 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", { diff --git a/web/src/components/dashboards/PanelEditor/types/panelEditor.ts b/web/src/components/dashboards/PanelEditor/types/panelEditor.ts index eb100abf59..3ad5a3b302 100644 --- a/web/src/components/dashboards/PanelEditor/types/panelEditor.ts +++ b/web/src/components/dashboards/PanelEditor/types/panelEditor.ts @@ -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. diff --git a/web/src/components/dashboards/addPanel/DashboardQueryEditor.completionInputs.spec.ts b/web/src/components/dashboards/addPanel/DashboardQueryEditor.completionInputs.spec.ts index bc6e56c5a4..e89c6ad718 100644 --- a/web/src/components/dashboards/addPanel/DashboardQueryEditor.completionInputs.spec.ts +++ b/web/src/components/dashboards/addPanel/DashboardQueryEditor.completionInputs.spec.ts @@ -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: "
", + }; + const mountWith = (provide: Record) => + 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([]); + }); + }); }); diff --git a/web/src/components/dashboards/addPanel/DashboardQueryEditor.vue b/web/src/components/dashboards/addPanel/DashboardQueryEditor.vue index 8f813d656a..22e98a3559 100644 --- a/web/src/components/dashboards/addPanel/DashboardQueryEditor.vue +++ b/web/src/components/dashboards/addPanel/DashboardQueryEditor.vue @@ -257,7 +257,11 @@ along with this program. If not, see .
diff --git a/web/src/lib/feedback/Toast/useToast.spec.ts b/web/src/lib/feedback/Toast/useToast.spec.ts index 3050e19444..ba84743dbc 100644 --- a/web/src/lib/feedback/Toast/useToast.spec.ts +++ b/web/src/lib/feedback/Toast/useToast.spec.ts @@ -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); diff --git a/web/src/lib/feedback/Toast/useToast.ts b/web/src/lib/feedback/Toast/useToast.ts index 4e15142145..0603d633e7 100644 --- a/web/src/lib/feedback/Toast/useToast.ts +++ b/web/src/lib/feedback/Toast/useToast.ts @@ -80,7 +80,10 @@ if (typeof document !== "undefined") { // ── Public dismiss-function type ───────────────────────────────────────────── -type DismissFn = (replacement?: Pick) => void; +/** Closes the toast; `id` is its record's id, the same one a collapsed duplicate returns. */ +type DismissFn = ((replacement?: Pick) => 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) => { 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 { diff --git a/web/src/lib/overlay/Popover/OPopover.spec.ts b/web/src/lib/overlay/Popover/OPopover.spec.ts index f24e51f0ad..41f4cbd037 100644 --- a/web/src/lib/overlay/Popover/OPopover.spec.ts +++ b/web/src/lib/overlay/Popover/OPopover.spec.ts @@ -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: "", + default: '
Body
', + }, + }); + 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); + }); }); diff --git a/web/src/lib/overlay/Popover/OPopover.vue b/web/src/lib/overlay/Popover/OPopover.vue index 229ea5fdd7..44de03e182 100644 --- a/web/src/lib/overlay/Popover/OPopover.vue +++ b/web/src/lib/overlay/Popover/OPopover.vue @@ -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. diff --git a/web/src/locales/languages/ar-SA.json b/web/src/locales/languages/ar-SA.json index 0d0ea0e40d..ce4ede8f61 100644 --- a/web/src/locales/languages/ar-SA.json +++ b/web/src/locales/languages/ar-SA.json @@ -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": "تراجع" } } diff --git a/web/src/locales/languages/de-DE.json b/web/src/locales/languages/de-DE.json index 0bfbb83c18..812b36881d 100644 --- a/web/src/locales/languages/de-DE.json +++ b/web/src/locales/languages/de-DE.json @@ -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" } } diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json index 0b058bb143..3e048b8e2b 100644 --- a/web/src/locales/languages/en-US.json +++ b/web/src/locales/languages/en-US.json @@ -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" } } diff --git a/web/src/locales/languages/es-ES.json b/web/src/locales/languages/es-ES.json index 44fa26d70a..3f7ea9b8bd 100644 --- a/web/src/locales/languages/es-ES.json +++ b/web/src/locales/languages/es-ES.json @@ -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" } } diff --git a/web/src/locales/languages/fr-FR.json b/web/src/locales/languages/fr-FR.json index c17f6b7ace..079d711cd9 100644 --- a/web/src/locales/languages/fr-FR.json +++ b/web/src/locales/languages/fr-FR.json @@ -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" } } diff --git a/web/src/locales/languages/it-IT.json b/web/src/locales/languages/it-IT.json index 390c035d62..80dd04c77e 100644 --- a/web/src/locales/languages/it-IT.json +++ b/web/src/locales/languages/it-IT.json @@ -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" } } diff --git a/web/src/locales/languages/ja-JP.json b/web/src/locales/languages/ja-JP.json index de32766c5a..5d7997eaaa 100644 --- a/web/src/locales/languages/ja-JP.json +++ b/web/src/locales/languages/ja-JP.json @@ -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": "元に戻す" } } diff --git a/web/src/locales/languages/ko-KR.json b/web/src/locales/languages/ko-KR.json index cd48b3d0cd..cd98423565 100644 --- a/web/src/locales/languages/ko-KR.json +++ b/web/src/locales/languages/ko-KR.json @@ -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": "실행 취소" } } diff --git a/web/src/locales/languages/nl-NL.json b/web/src/locales/languages/nl-NL.json index 5c4a8c6eb0..223ff2e402 100644 --- a/web/src/locales/languages/nl-NL.json +++ b/web/src/locales/languages/nl-NL.json @@ -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" } } diff --git a/web/src/locales/languages/pl-PL.json b/web/src/locales/languages/pl-PL.json index 3b4025c922..98c292bc86 100644 --- a/web/src/locales/languages/pl-PL.json +++ b/web/src/locales/languages/pl-PL.json @@ -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" } } diff --git a/web/src/locales/languages/pt-PT.json b/web/src/locales/languages/pt-PT.json index db092280ef..9d93f507a2 100644 --- a/web/src/locales/languages/pt-PT.json +++ b/web/src/locales/languages/pt-PT.json @@ -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" } } diff --git a/web/src/locales/languages/ru-RU.json b/web/src/locales/languages/ru-RU.json index 1328ffc8a7..0a61227687 100644 --- a/web/src/locales/languages/ru-RU.json +++ b/web/src/locales/languages/ru-RU.json @@ -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": "Отменить" } } diff --git a/web/src/locales/languages/tr-TR.json b/web/src/locales/languages/tr-TR.json index 1adcac8285..1a7c38c8c9 100644 --- a/web/src/locales/languages/tr-TR.json +++ b/web/src/locales/languages/tr-TR.json @@ -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" } } diff --git a/web/src/locales/languages/vi-VN.json b/web/src/locales/languages/vi-VN.json index 9eff9a4f60..65165774ff 100644 --- a/web/src/locales/languages/vi-VN.json +++ b/web/src/locales/languages/vi-VN.json @@ -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" } } diff --git a/web/src/locales/languages/zh-CN.json b/web/src/locales/languages/zh-CN.json index c02a051091..8474ac0ff3 100644 --- a/web/src/locales/languages/zh-CN.json +++ b/web/src/locales/languages/zh-CN.json @@ -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": "撤销" } } diff --git a/web/src/locales/languages/zh-TW.json b/web/src/locales/languages/zh-TW.json index fcf551d5f8..61569e84df 100644 --- a/web/src/locales/languages/zh-TW.json +++ b/web/src/locales/languages/zh-TW.json @@ -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": "復原" } } diff --git a/web/src/plugins/logs/Index.recentReapply.spec.ts b/web/src/plugins/logs/Index.recentReapply.spec.ts new file mode 100644 index 0000000000..428ae5c52d --- /dev/null +++ b/web/src/plugins/logs/Index.recentReapply.spec.ts @@ -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 . + +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(); + 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>; + +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).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).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); + }); +}); diff --git a/web/src/plugins/logs/Index.vue b/web/src/plugins/logs/Index.vue index 1d99872a75..4598c16fda 100644 --- a/web/src/plugins/logs/Index.vue +++ b/web/src/plugins/logs/Index.vue @@ -328,7 +328,7 @@ along with this program. If not, see . :query-window-us="queryWindowUs" :timezone="store.state.timezone" @jump-to-stream-data="onJumpToStreamData" - @open-history="showSearchHistoryfn" + @open-history="openSearchHistory" @ask-ai="onAskAiFixQuery" />
@@ -567,7 +567,7 @@ along with this program. If not, see . :query-window-us="queryWindowUs" :timezone="store.state.timezone" @jump-to-stream-data="onJumpToStreamData" - @open-history="showSearchHistoryfn" + @open-history="openSearchHistory" @ask-ai="onAskAiFixQuery" /> 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, diff --git a/web/src/plugins/logs/SearchBar.recentFocusEscape.spec.ts b/web/src/plugins/logs/SearchBar.recentFocusEscape.spec.ts new file mode 100644 index 0000000000..ba4ee9c087 --- /dev/null +++ b/web/src/plugins/logs/SearchBar.recentFocusEscape.spec.ts @@ -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 . + +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 }; + }; + 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); + }); + }); +}); diff --git a/web/src/plugins/logs/SearchBar.vue b/web/src/plugins/logs/SearchBar.vue index b4dd00237e..47927d5c98 100644 --- a/web/src/plugins/logs/SearchBar.vue +++ b/web/src/plugins/logs/SearchBar.vue @@ -694,7 +694,7 @@ along with this program. If not, see . - {{ t("search.searchHistory") }} + {{ t("search.usageCostHistory") }} @@ -999,6 +999,15 @@ along with this program. If not, see . +
. @focus="onQueryEditorFocus" @blur="handleQueryEditorBlur" /> +
@@ -1429,6 +1455,7 @@ along with this program. If not, see .
+ 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(null); + const recentFocusRef = ref | null>(null); + const recentDrawerRef = ref | 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, diff --git a/web/src/plugins/metrics/Index.history.spec.ts b/web/src/plugins/metrics/Index.history.spec.ts index 01c7c1bcda..824b284bf6 100644 --- a/web/src/plugins/metrics/Index.history.spec.ts +++ b/web/src/plugins/metrics/Index.history.spec.ts @@ -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 } | 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: '
', }, PanelEditor: { - setup: (_: any, { expose }: any) => { + emits: ["chartApiError", "renderer-error"], + setup: (_: unknown, { expose, emit }: SetupContext) => { injected.runQuery = inject("runQuery", null); + panelRun.state = inject("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 ", code: 400 }); + + expect(api.record).toHaveBeenCalledTimes(1); + expect(api.record.mock.calls[0][1]).toMatchObject({ + status: "error", + context: { error: "parse error: unexpected " }, + }); + }); + + 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(); }); }); diff --git a/web/src/plugins/metrics/Index.vue b/web/src/plugins/metrics/Index.vue index 7d57bc94eb..0fdb411568 100644 --- a/web/src/plugins/metrics/Index.vue +++ b/web/src/plugins/metrics/Index.vue @@ -58,8 +58,24 @@ along with this program. If not, see . shortcut-id="metricsCopyUrl" class="h-8" /> - + +