From 07ae6fc16364205931674c53706dda88ec9f83d3 Mon Sep 17 00:00:00 2001 From: Hengfei Yang Date: Mon, 2 Nov 2026 11:42:36 +0000 Subject: [PATCH 01/11] chore: say fleet tokens get 401 when fleet is disabled (#16243) --- src/api/http/src/handler/http/router/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/http/src/handler/http/router/mod.rs b/src/api/http/src/handler/http/router/mod.rs index f23dfc72da..f26de02a13 100644 --- a/src/api/http/src/handler/http/router/mod.rs +++ b/src/api/http/src/handler/http/router/mod.rs @@ -2604,7 +2604,7 @@ fn fleet_router() -> Router { .route_layer(middleware::from_fn(fleet_gate)) } -/// §17: with `O2_FLEET_ENABLED` off, OpAMP answers 503 and management reads work but writes do not. +/// §17: with `O2_FLEET_ENABLED` off, auth refuses fleet tokens first (401); writes get 503. #[cfg(feature = "enterprise")] async fn fleet_gate(request: Request, next: Next) -> Response { if openobserve_core::fleet::enabled() || request.method() == Method::GET && !is_opamp(&request) From 46a85bb9cc8c10c670a404a0e74f85177d794c3f Mon Sep 17 00:00:00 2001 From: Huaijin Date: Mon, 2 Nov 2026 11:43:42 +0000 Subject: [PATCH 02/11] fix: skip compactor jobs for streams whose key cannot be stored (#16231) --- src/compaction/src/dump.rs | 27 ++++++++++++++++++++++++++- src/compaction/src/lib.rs | 11 +++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/compaction/src/dump.rs b/src/compaction/src/dump.rs index 1178c751ae..ac183c1bc6 100644 --- a/src/compaction/src/dump.rs +++ b/src/compaction/src/dump.rs @@ -206,6 +206,15 @@ pub async fn dump(job: &DumpJob) -> Result<(), anyhow::Error> { job.offset ); + let dump_stream_name = generate_dump_stream_name(job.stream_type, &job.stream_name); + // the dump key outgrows the source's by 10, so a source near the limit cannot be dumped + if infra_file_list::stream_key_violation(&job.org_id, StreamType::Filelist, &dump_stream_name) + .is_some() + { + infra_file_list::set_job_dumped_status(&[job.job_id], true).await?; + return Ok(()); + } + // check offset let partition_time_level = get_partition_time_level(job.stream_type); let offset_time: DateTime = Utc.timestamp_nanos(job.offset * 1000); @@ -259,7 +268,6 @@ pub async fn dump(job: &DumpJob) -> Result<(), anyhow::Error> { } // check if the schema need to be updated - let dump_stream_name = generate_dump_stream_name(job.stream_type, &job.stream_name); let dump_stream_key = format!( "{}/{}/{}", job.org_id, @@ -820,6 +828,23 @@ mod tests { use super::*; + #[test] + fn test_dump_key_of_a_source_at_the_limit_cannot_be_stored() { + let org_id = "org"; + let source = + "s".repeat(infra::file_list::STREAM_KEY_MAX_LEN - org_id.len() - "/logs/".len()); + assert!(infra_file_list::stream_key_violation(org_id, StreamType::Logs, &source).is_none()); + let bounded = + infra_file_list::stream_key_violation(org_id, StreamType::Logs, &format!("{source}s")) + .is_some(); + let dump_stream = generate_dump_stream_name(StreamType::Logs, &source); + assert_eq!( + infra_file_list::stream_key_violation(org_id, StreamType::Filelist, &dump_stream) + .is_some(), + bounded + ); + } + // ---- DumpJob ---- #[test] diff --git a/src/compaction/src/lib.rs b/src/compaction/src/lib.rs index a8b4634023..0d7ad6f26f 100644 --- a/src/compaction/src/lib.rs +++ b/src/compaction/src/lib.rs @@ -152,6 +152,12 @@ pub async fn run_generate_job(job_type: CompactionJobType) -> Result<(), anyhow: ); continue; } + // a stream created before over-long keys were rejected can never record a job + if infra_file_list::stream_key_violation(&org_id, stream_type, &stream_name) + .is_some() + { + continue; + } match job_type { CompactionJobType::Current => { @@ -245,6 +251,11 @@ pub async fn run_generate_downsampling_job() -> Result<(), anyhow::Error> { ); continue; } + if infra_file_list::stream_key_violation(&org_id, stream_type, &stream_name) + .is_some() + { + continue; + } if let Err(e) = merge::generate_downsampling_job_by_stream_and_rule( &org_id, From b42ff72f2eff3c692276363add9e44c133a98bf8 Mon Sep 17 00:00:00 2001 From: Shrinath Rao Date: Mon, 2 Nov 2026 11:52:29 +0000 Subject: [PATCH 03/11] chore: document PromQL 400/403/429 responses (#16224) Co-authored-by: Ashish Kolhe <35160958+oasisk@users.noreply.github.com> --- .../http/src/handler/http/router/openapi.rs | 14 ++++++++++++++ src/api/search/src/promql/mod.rs | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/api/http/src/handler/http/router/openapi.rs b/src/api/http/src/handler/http/router/openapi.rs index a834b8ab6f..dd3144bfc6 100644 --- a/src/api/http/src/handler/http/router/openapi.rs +++ b/src/api/http/src/handler/http/router/openapi.rs @@ -94,6 +94,7 @@ use crate::{ openobserve_api_search::promql::remote_write, openobserve_api_search::promql::query_get, openobserve_api_search::promql::query_range_get, + openobserve_api_search::promql::query_exemplars_get, openobserve_api_search::promql::metadata, openobserve_api_search::promql::series_get, openobserve_api_search::promql::labels_get, @@ -1714,6 +1715,19 @@ mod tests { } } + #[test] + fn promql_exemplars_is_published_with_its_error_codes() { + let spec = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let op = &spec["paths"]["/api/{org_id}/prometheus/api/v1/query_exemplars"]["get"]; + assert_eq!(op["operationId"], "PrometheusQueryExemplars"); + for status in ["200", "400", "403", "429", "500"] { + assert!( + op["responses"].get(status).is_some(), + "query_exemplars {status}" + ); + } + } + #[test] fn recent_rejections_path_and_schemas_are_registered() { let spec = serde_json::to_value(ApiDoc::openapi()).unwrap(); diff --git a/src/api/search/src/promql/mod.rs b/src/api/search/src/promql/mod.rs index fa5a365cf5..ebc82e67a9 100644 --- a/src/api/search/src/promql/mod.rs +++ b/src/api/search/src/promql/mod.rs @@ -60,6 +60,7 @@ use crate::{ request_body(content = String, description = "prometheus WriteRequest", content_type = "application/x-protobuf"), responses( (status = 200, description = "Success", content_type = "application/json", body = Object, example = json!({"code": 200})), + (status = 400, description = "Not a protobuf body or the payload cannot be ingested", content_type = "application/json", body = ()), (status = 500, description = "Failure", content_type = "application/json", body = ()), ), extensions( @@ -132,6 +133,9 @@ pub async fn remote_write( ] } })), + (status = 400, description = "Invalid query or time", content_type = "application/json", body = ()), + (status = 403, description = "No permission on the metric stream", content_type = "application/json", body = ()), + (status = 429, description = "Search disabled after exceeding the licensed ingestion limit, or rate limited", content_type = "application/json", body = ()), (status = 500, description = "Failure", content_type = "application/json", body = ()), ), extensions( @@ -304,6 +308,9 @@ async fn query( ] } })), + (status = 400, description = "Invalid query, time range, step or search type", content_type = "application/json", body = ()), + (status = 403, description = "No permission on the metric stream", content_type = "application/json", body = ()), + (status = 429, description = "Search disabled after exceeding the licensed ingestion limit, or rate limited", content_type = "application/json", body = ()), (status = 500, description = "Failure", content_type = "application/json", body = ()), ), extensions( @@ -398,6 +405,9 @@ pub async fn query_range_post( } ] })), + (status = 400, description = "Invalid query, time range, step or search type", content_type = "application/json", body = ()), + (status = 403, description = "No permission on the metric stream", content_type = "application/json", body = ()), + (status = 429, description = "Search disabled after exceeding the licensed ingestion limit, or rate limited", content_type = "application/json", body = ()), (status = 500, description = "Failure", content_type = "application/json", body = ()), ), extensions( @@ -626,6 +636,7 @@ async fn query_range( ] } })), + (status = 403, description = "No permission to read metrics", content_type = "application/json", body = ()), (status = 500, description = "Failure", content_type = "application/json", body = ()), ), extensions( @@ -750,6 +761,9 @@ pub async fn metadata( } ] })), + (status = 400, description = "Invalid match[] or time range", content_type = "application/json", body = ()), + (status = 403, description = "No permission on the metric stream", content_type = "application/json", body = ()), + (status = 429, description = "Search disabled after exceeding the licensed ingestion limit, or rate limited", content_type = "application/json", body = ()), (status = 500, description = "Failure", content_type = "application/json", body = ()), ), extensions( @@ -883,6 +897,8 @@ async fn series( "version" ] })), + (status = 400, description = "Invalid match[] or time range", content_type = "application/json", body = ()), + (status = 403, description = "No permission to read metrics", content_type = "application/json", body = ()), (status = 500, description = "Failure", content_type = "application/json", body = ()), ), extensions( @@ -1024,6 +1040,7 @@ async fn labels( ] })), (status = 400, description = "Invalid parameters or match[] does not identify a metric", content_type = "application/json", body = ()), + (status = 403, description = "No permission to read metrics", content_type = "application/json", body = ()), (status = 500, description = "Failure", content_type = "application/json", body = ()), ), extensions( @@ -1246,6 +1263,7 @@ fn validate_range_window(start: i64, end: i64) -> Result<(), String> { "status" : "success", "data" : "foo / bar" })), + (status = 400, description = "The query does not parse", content_type = "application/json", body = ()), (status = 500, description = "Failure", content_type = "application/json", body = ()), ), extensions( From 52bbd1634ecc9f887dce6953426509a389b8b149 Mon Sep 17 00:00:00 2001 From: Ashish Kolhe <35160958+oasisk@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:18:56 +0000 Subject: [PATCH 04/11] fix(ui): name the right permission and surface every RBAC denial (#16204) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- scripts/translations/.translation_state.json | 70 +- .../access/OAccessActionPopover.spec.ts | 188 +++ .../access/OAccessActionPopover.vue | 70 +- .../components/access/OAccessBoundary.spec.ts | 107 ++ web/src/components/access/OAccessBoundary.vue | 20 +- .../components/access/OAccessDeniedCard.vue | 7 +- .../access/OAccessHeaderChip.spec.ts | 121 ++ .../components/access/OAccessHeaderChip.vue | 44 +- web/src/components/access/OAccessLock.vue | 28 +- .../components/access/surfaceDenials.spec.ts | 77 ++ web/src/components/access/surfaceDenials.ts | 57 + .../components/alerts/AddDestination.spec.ts | 29 + web/src/components/alerts/AddDestination.vue | 4 +- web/src/components/alerts/AddTemplate.spec.ts | 23 + web/src/components/alerts/AddTemplate.vue | 2 +- web/src/components/alerts/AlertList.spec.ts | 113 ++ web/src/components/alerts/AlertList.vue | 23 +- web/src/components/functions/FunctionList.vue | 1 + web/src/components/iam/groups/AppGroups.vue | 1 + web/src/components/iam/roles/AppRoles.vue | 1 + .../serviceAccounts/ServiceAccountsList.vue | 1 + web/src/components/iam/users/User.vue | 1 + .../ingestion/FirstDataPanel.spec.ts | 13 + .../components/ingestion/FirstDataPanel.vue | 1 + .../NodeForm/CreateDestinationForm.spec.ts | 38 + .../NodeForm/CreateDestinationForm.vue | 4 +- .../settings/OrgStorageEditor.spec.ts | 19 + .../components/settings/OrgStorageEditor.vue | 2 +- web/src/composables/shared/router.ts | 3 + .../composables/shared/useEnterpriseRoutes.ts | 4 +- .../composables/shared/useManagementRoutes.ts | 1 + .../useAccessStore.classify.spec.ts | 996 +++++++++++++- web/src/composables/useAccessStore.spec.ts | 116 +- web/src/composables/useAccessStore.ts | 1203 ++++++++++++++--- web/src/composables/useHomeDashboard.spec.ts | 3 + web/src/composables/useHomeDashboard.ts | 4 +- .../lib/core/PageHeader/OPageHeader.spec.ts | 205 +++ web/src/lib/core/PageHeader/OPageHeader.vue | 40 +- web/src/lib/core/Table/OTable.spec.ts | 21 + web/src/lib/core/Table/OTable.types.ts | 2 + web/src/lib/core/Table/OTable.vue | 10 +- web/src/lib/core/Table/tableForbiddenState.ts | 24 + web/src/lib/overlay/Popover/OPopover.spec.ts | 16 + web/src/lib/overlay/Popover/OPopover.vue | 9 +- web/src/locales/languages/ar-SA.json | 72 +- web/src/locales/languages/de-DE.json | 72 +- web/src/locales/languages/en-US.json | 72 +- web/src/locales/languages/es-ES.json | 72 +- web/src/locales/languages/fr-FR.json | 72 +- web/src/locales/languages/it-IT.json | 72 +- web/src/locales/languages/ja-JP.json | 72 +- web/src/locales/languages/ko-KR.json | 72 +- web/src/locales/languages/nl-NL.json | 72 +- web/src/locales/languages/pl-PL.json | 72 +- web/src/locales/languages/pt-PT.json | 72 +- web/src/locales/languages/ru-RU.json | 72 +- web/src/locales/languages/th-TH.json | 72 +- web/src/locales/languages/tr-TR.json | 72 +- web/src/locales/languages/vi-VN.json | 72 +- web/src/locales/languages/zh-CN.json | 72 +- web/src/locales/languages/zh-TW.json | 72 +- web/src/main.spec.ts | 22 +- web/src/main.ts | 3 + .../plugins/logs/SearchBar.autoRun.spec.ts | 10 +- web/src/plugins/logs/SearchBar.vue | 11 +- web/src/services/settings.spec.ts | 13 + web/src/services/settings.ts | 3 +- web/src/types/vue-router.d.ts | 2 + 68 files changed, 4682 insertions(+), 328 deletions(-) create mode 100644 web/src/components/access/OAccessActionPopover.spec.ts create mode 100644 web/src/components/access/OAccessBoundary.spec.ts create mode 100644 web/src/components/access/OAccessHeaderChip.spec.ts create mode 100644 web/src/components/access/surfaceDenials.spec.ts create mode 100644 web/src/components/access/surfaceDenials.ts create mode 100644 web/src/lib/core/PageHeader/OPageHeader.spec.ts create mode 100644 web/src/lib/core/Table/tableForbiddenState.ts diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index e4c6a0dead..40cc6c36fe 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -375,6 +375,8 @@ "copied": "ea61bc15688d1e48", "copyDetails": "ec7ee2826c849564", "denied": { + "actions": "ad5a1de1c7b8b675", + "askAdmin": "01ecb087fe129ce1", "create": "e64bc3593a603832", "delete": "c346b3c06e9303ed", "edit": "6649052d1364e44e", @@ -382,57 +384,120 @@ "missingIntro": "475cc73919dd1e9d", "more": "5f4f590acbda8ee3", "parts": "8b82b8f1aed3dad5", + "resources": "cd3b36d4f31da787", "showLess": "94ea9b1d33a02975", "view": "27e9f709ef02acf1" }, "resources": { "ai": "11fb682be0a0233d", + "alertFolders": "a6e2acee332f6d23", "alerts": "f62e9267d196c761", + "billingGroup": "3a0cc47e1a82ca70", "cipherKeys": "9d5e36b817bf496e", + "dashboardFolders": "62ec1a77e94fbcb4", "dashboards": "a53bcafb67d960df", + "databaseMonitoring": "8f614378c817c981", "destinations": "72eb63f032e47e34", + "downtimeFolders": "8d359247ac73379d", "downtimes": "5d9972eaed771890", + "enrichmentTables": "271f9c6c3357a0b6", + "evalJobs": "e24b61f0397c85fd", "fleetConfigs": "affc3f6b5f45623a", + "fleetManagement": "734376ce61f58376", "functions": "75e942e5b366227e", "groups": "39bbb719fa2b9d22", + "incidentIntegrations": "28df794d2558a7c3", + "incidents": "bfe868931580fb3b", "llmModelPricing": "794f915f46e1cc64", + "llmProviders": "437dbe28117905aa", + "metrics": "a58da793c7250f1b", + "onCall": "3004e1251d8890eb", + "onCallResponses": "a330bac7444925ef", "organizations": "2730183d6b776b40", "passcode": "df5f51e2e793f3c9", "pipelines": "d1a8fffeb935847e", + "promptFolders": "f92c9201b9ff5bb1", "publicDashboards": "b4bee918655d4e11", "regexPatterns": "36e205848147c947", + "remoteTasks": "10d46c5392001dce", + "reportFolders": "515a865d77faf262", "reports": "dacca3cba3f346a4", "roles": "c25337055464f8d3", + "rumTokens": "d7da49dc65e2dbf8", "savedviews": "81184cb517eda985", + "scoreConfigs": "7d19a0ae2c76ced2", + "scorers": "d8598427499abb65", + "searchJobs": "989ecb5d07fd4c76", "serviceAccounts": "1642d9e225e63659", + "serviceStreams": "e3c04b59334f2bd1", + "settings": "74a883a037bc227f", + "sourceMaps": "b95b75c0ca503146", + "statusPages": "8fe24747e79435f9", "streams": "bfd297afbf9e7430", + "syntheticChecks": "fe7120bc3b396719", + "syntheticEnvironments": "a4b9982963b2f048", + "syntheticFolders": "48bdc3235b5ea3ea", "templates": "56b564b75c7fdb48", - "users": "6b0cc904d081a8c1" + "traces": "998741a58ed2b411", + "users": "6b0cc904d081a8c1", + "workflowFolders": "8e6af10f9d904da1", + "workflows": "e2073645cb0dbbdd" }, "resourcesInline": { "ai": "11fb682be0a0233d", + "alertFolders": "d2a7365a5fcea5bd", "alerts": "deb3e366c077c228", + "billingGroup": "988507f87cfea865", "cipherKeys": "a4ec922695af251c", + "dashboardFolders": "b3505579443c0ff4", "dashboards": "21b2e2095fd38f7d", + "databaseMonitoring": "d70b640c5e7e70cb", "destinations": "1af0500e4d2be663", + "downtimeFolders": "20ee5e166973b24f", "downtimes": "573ef711d75684dd", + "enrichmentTables": "2523e745479b9694", + "evalJobs": "3fcc2bfe3200a445", "fleetConfigs": "fa54fb3d9e25631c", + "fleetManagement": "a3ff8c3b237fa163", "functions": "ef7d009c7e812da1", "groups": "4ed379d418bb8629", + "incidentIntegrations": "c04904d8df7467f6", + "incidents": "a6d1765ab64d82a7", "llmModelPricing": "794f915f46e1cc64", + "llmProviders": "437dbe28117905aa", + "metrics": "177a7ea3611fe6b1", + "onCall": "3004e1251d8890eb", + "onCallResponses": "a330bac7444925ef", "organizations": "f8a665a97769f1d2", "passcode": "583375d251142f0d", "pipelines": "8719e4b9771d340e", + "promptFolders": "ac11560dd26704d4", "publicDashboards": "55ff9bd4571fa4cb", "regexPatterns": "f2677e20964b4314", + "remoteTasks": "63d70d9b7b8e801d", + "reportFolders": "ac9ce060cfb53c7b", "reports": "7f26104f77a7ce47", "roles": "66cf5513b37462be", + "rumTokens": "d7da49dc65e2dbf8", "savedviews": "1b46920dbc530fb7", + "scoreConfigs": "189e152b76d63e8a", + "scorers": "2b0f61e1cf254296", + "searchJobs": "6722117cf5ce7eda", "serviceAccounts": "7c40134810b0f1e1", + "serviceStreams": "8d5894bc8ffa2192", + "settings": "cde0fb0dec1400c5", + "sourceMaps": "08cd5f94f175b0d2", + "statusPages": "449ab3614f7fecf1", "streams": "d5a08b6893ecd1aa", + "syntheticChecks": "6e01d2051842b5a1", + "syntheticEnvironments": "a902961a1e4e24a3", + "syntheticFolders": "b8fe6356a3da347a", "templates": "9c6641c1f9cfb0c6", + "traces": "b5ac69fc467c01f3", "unnamed": "644ebf22501f57b5", - "users": "7dfb4cf67742cb06" + "users": "7dfb4cf67742cb06", + "workflowFolders": "9c0544bd232b9a33", + "workflows": "45b34f16db605a06" } }, "aiAssistant": { @@ -23324,7 +23389,6 @@ "searchField": "7909dc4e707ddddb", "searchHistory": "66d7e4a1c738f0b1", "searchInspect": "bac72e15bee2bc8a", - "searchJobNoPermission": "e0038dd68dcec8eb", "searchOperationCancelled": "2429bc389adabe7f", "searchQueryEmptyOrInvalid": "7682ef6f589ed1ea", "searchSavedFunction": "49c266baaaa70981", diff --git a/web/src/components/access/OAccessActionPopover.spec.ts b/web/src/components/access/OAccessActionPopover.spec.ts new file mode 100644 index 0000000000..a984a4aab4 --- /dev/null +++ b/web/src/components/access/OAccessActionPopover.spec.ts @@ -0,0 +1,188 @@ +// 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, type VueWrapper } from "@vue/test-utils"; +import { shallowRef, type ShallowRef } from "vue"; +import i18n from "@/locales"; +import OAccessActionPopover from "@/components/access/OAccessActionPopover.vue"; +import { viewDenial, type AccessActionNotice } from "@/composables/useAccessStore"; + +const state = vi.hoisted(() => ({ + notice: null as unknown as ShallowRef, + dismissAction: null as unknown as () => void, +})); + +vi.mock("@/composables/useAccessStore", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAccessBoundary: () => ({ actionNotice: state.notice, dismissAction: state.dismissAction }), + }; +}); + +let wrapper: VueWrapper | null = null; +let container: HTMLElement; +let anchor: HTMLButtonElement; + +const card = () => document.querySelector('[data-test="access-action-error"]'); +const settle = async () => { + await flushPromises(); + await new Promise((resolve) => setTimeout(resolve, 20)); +}; +const press = (el: Element, key: string, init: KeyboardEventInit = {}) => + el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init })); + +async function openCard( + denials = [viewDenial("alerts", "delete", ["DELETE /api/default/alerts/a1"])], +): Promise { + wrapper = mount(OAccessActionPopover, { attachTo: document.body, global: { plugins: [i18n] } }); + state.notice.value = { + el: anchor, + permission: denials[0].permission, + method: "DELETE", + resource: denials[0].resource, + denials, + routeKey: "alerts", + fallbacks: [], + }; + await settle(); +} + +beforeEach(() => { + state.notice = shallowRef(null); + state.dismissAction = vi.fn(() => (state.notice.value = null)); + container = document.createElement("div"); + anchor = document.createElement("button"); + anchor.textContent = "Delete"; + container.appendChild(anchor); + document.body.appendChild(container); + anchor.focus(); +}); + +afterEach(() => { + wrapper?.unmount(); + wrapper = null; + container.remove(); +}); + +describe("OAccessActionPopover", () => { + it("keeps focus on the pressed control when the card opens", async () => { + await openCard(); + expect(card()).not.toBeNull(); + expect(document.activeElement).toBe(anchor); + }); + + it("names every refused write the one action sent", async () => { + await openCard([ + viewDenial("roles", "update", ["PUT /api/default/roles/viewer"]), + viewDenial("groups", "update", ["PUT /api/default/groups/ops"]), + ]); + const text = card()!.textContent ?? ""; + expect(text).toContain("Roles"); + expect(text).toContain("Groups"); + }); + + it("moves Tab from the pressed control into the card instead of closing it", async () => { + await openCard(); + press(anchor, "Tab"); + await settle(); + expect(state.dismissAction).not.toHaveBeenCalled(); + expect(card()?.contains(document.activeElement)).toBe(true); + }); + + it("stays open when focus returns to the pressed control", async () => { + await openCard(); + card()!.focus(); + anchor.focus(); + await settle(); + expect(state.dismissAction).not.toHaveBeenCalled(); + expect(card()).not.toBeNull(); + }); + + it("returns to the pressed control when Tab leaves either end of the card", async () => { + await openCard(); + card()!.focus(); + press(card()!, "Tab", { shiftKey: true }); + expect(document.activeElement).toBe(anchor); + expect(state.dismissAction).not.toHaveBeenCalled(); + + const copy = card()!.querySelector('[data-test="access-denied-copy"]')!; + copy.focus(); + press(copy, "Tab"); + await settle(); + expect(document.activeElement).toBe(anchor); + expect(state.dismissAction).toHaveBeenCalledTimes(1); + }); + + it("returns Shift+Tab from the first card button to the pressed control", async () => { + await openCard(); + const copy = card()!.querySelector('[data-test="access-denied-copy"]')!; + copy.focus(); + const event = new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + }); + copy.dispatchEvent(event); + await settle(); + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(anchor); + expect(state.dismissAction).not.toHaveBeenCalled(); + }); + + it("returns Shift+Tab from the first of several card buttons, and leaves the others to the browser", async () => { + await openCard( + ["dashboards", "alerts", "streams", "functions", "pipelines", "destinations"].map((key) => + viewDenial(key, "delete", [`DELETE /api/default/${key}/x`]), + ), + ); + const buttons = [...card()!.querySelectorAll("button")]; + expect(buttons[0].dataset.test).toBe("access-denied-more"); + expect(buttons.length).toBeGreaterThan(1); + buttons[1].focus(); + const inner = new KeyboardEvent("keydown", { + key: "Tab", + shiftKey: true, + bubbles: true, + cancelable: true, + }); + buttons[1].dispatchEvent(inner); + expect(inner.defaultPrevented).toBe(false); + buttons[0].focus(); + press(buttons[0], "Tab", { shiftKey: true }); + await settle(); + expect(document.activeElement).toBe(anchor); + expect(state.dismissAction).not.toHaveBeenCalled(); + }); + + it("closes on Escape and gives focus back to the pressed control", async () => { + await openCard(); + card()!.focus(); + press(card()!, "Escape"); + await settle(); + expect(state.dismissAction).toHaveBeenCalled(); + expect(document.activeElement).toBe(anchor); + }); + + it("stays open while the page under it scrolls", async () => { + await openCard(); + container.dispatchEvent(new Event("scroll")); + await settle(); + expect(state.dismissAction).not.toHaveBeenCalled(); + expect(card()).not.toBeNull(); + }); +}); diff --git a/web/src/components/access/OAccessActionPopover.vue b/web/src/components/access/OAccessActionPopover.vue index 91cfbdd0d0..3c6177dc63 100644 --- a/web/src/components/access/OAccessActionPopover.vue +++ b/web/src/components/access/OAccessActionPopover.vue @@ -22,13 +22,21 @@ along with this program. If not, see . align="end" flip-align arrow + :close-on-scroll="false" :side-offset="-1" :collision-padding="8" content-class="border-0!" @update:open="onOpenChange" @open-auto-focus="keepFocus" + @focus-outside="keepWhileOnAnchor" > - + diff --git a/web/src/components/access/OAccessBoundary.spec.ts b/web/src/components/access/OAccessBoundary.spec.ts new file mode 100644 index 0000000000..cbf7eb254c --- /dev/null +++ b/web/src/components/access/OAccessBoundary.spec.ts @@ -0,0 +1,107 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { computed, defineComponent, h, ref, shallowRef } from "vue"; +import { createMemoryHistory, createRouter } from "vue-router"; +import i18n from "@/locales"; +import OAccessBoundary from "@/components/access/OAccessBoundary.vue"; +import OTable from "@/lib/core/Table/OTable.vue"; +import { viewDenial, type AccessSectionEntry } from "@/composables/useAccessStore"; + +const state = vi.hoisted(() => ({ entries: [] as AccessSectionEntry[] })); + +vi.mock("@/composables/useAccessStore", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAccessBoundary: () => ({ + pageDenied: computed(() => false), + pageDenial: computed(() => null), + pageRequests: () => [], + sectionEntries: computed(() => state.entries), + deepestBoundary: computed(() => -1), + actionNotice: shallowRef(null), + dismissAction: vi.fn(), + }), + }; +}); + +let wrapper: VueWrapper | null = null; + +async function mountTable(entries: AccessSectionEntry[], forbiddenResource?: string) { + state.entries = entries; + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: "/", component: { render: () => null } }], + }); + await router.push("/"); + const Page = defineComponent({ + setup: () => () => + h(OTable, { + data: [], + columns: [{ id: "name", header: "Name", accessorKey: "name" }], + forbidden: true, + forbiddenResource, + loading: ref(false).value, + }), + }); + wrapper = mount(OAccessBoundary, { + slots: { default: () => h(Page) }, + global: { plugins: [i18n, router] }, + }); + await flushPromises(); + return wrapper.get('[data-test="o2-table-forbidden"]'); +} + +afterEach(() => { + wrapper?.unmount(); + wrapper = null; +}); + +describe("OAccessBoundary table no-access state", () => { + it("shows the shared lock naming the table's refused list", async () => { + const forbidden = await mountTable( + [ + viewDenial("templates", "list", ["GET /api/default/alerts/templates"]), + viewDenial("alert_folders", "list", ["GET /api/v2/default/alerts"]), + ], + "alert_folders", + ); + expect(forbidden.find('[data-test="access-lock-icon"]').exists()).toBe(true); + expect(forbidden.text()).toContain("You can't view alert folders"); + expect(forbidden.text()).toContain( + "Your role is missing the List permission on Alert folders.", + ); + expect(forbidden.text()).toContain("Copy details"); + }); + + it("keeps the shared lock with the general wording when it cannot tell which list was refused", async () => { + const forbidden = await mountTable([ + viewDenial("templates", "list"), + viewDenial("destinations", "list"), + ]); + expect(forbidden.find('[data-test="access-lock-icon"]').exists()).toBe(true); + expect(forbidden.text()).toContain("You don't have access"); + expect(forbidden.text()).not.toContain("Copy details"); + }); + + it("does not name the page's only refused list on a table that names no resource", async () => { + const forbidden = await mountTable([viewDenial("templates", "list")]); + expect(forbidden.text()).toContain("You don't have access"); + expect(forbidden.text()).not.toContain("templates"); + }); +}); diff --git a/web/src/components/access/OAccessBoundary.vue b/web/src/components/access/OAccessBoundary.vue index e81a075d54..120715f9c1 100644 --- a/web/src/components/access/OAccessBoundary.vue +++ b/web/src/components/access/OAccessBoundary.vue @@ -37,12 +37,22 @@ along with this program. If not, see . diff --git a/web/src/components/access/OAccessDeniedCard.vue b/web/src/components/access/OAccessDeniedCard.vue index 2f19e99d54..671c36e99f 100644 --- a/web/src/components/access/OAccessDeniedCard.vue +++ b/web/src/components/access/OAccessDeniedCard.vue @@ -16,7 +16,7 @@ along with this program. If not, see . - +

+ {{ t("accessBoundary.denied.askAdmin") }} +

. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount, type VueWrapper } from "@vue/test-utils"; +import { computed, shallowRef, type ShallowRef } from "vue"; +import i18n from "@/locales"; +import OAccessHeaderChip from "@/components/access/OAccessHeaderChip.vue"; +import { viewDenial, type AccessSectionEntry } from "@/composables/useAccessStore"; + +const state = vi.hoisted(() => ({ + entries: null as unknown as ShallowRef, +})); + +const screen = vi.hoisted(() => ({ lgUp: true })); +vi.mock("@/composables/useBreakpoint", () => ({ + default: () => ({ lgUp: computed(() => screen.lgUp) }), +})); + +vi.mock("@/composables/useAccessStore", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAccessBoundary: () => ({ sectionEntries: state.entries }) }; +}); + +let wrapper: VueWrapper | null = null; + +const announcer = () => wrapper!.find('[data-test="access-header-chip-announcer"]'); +const chipName = () => wrapper!.find('[data-test="access-header-chip"]').attributes("aria-label"); +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +beforeEach(() => { + screen.lgUp = true; + state.entries = shallowRef([viewDenial("templates", "list", ["GET /api/default/templates"])]); + wrapper = mount(OAccessHeaderChip, { attachTo: document.body, global: { plugins: [i18n] } }); +}); + +afterEach(() => { + wrapper?.unmount(); + wrapper = null; +}); + +describe("OAccessHeaderChip", () => { + it("announces the chip once through a polite live region", async () => { + const region = announcer(); + expect(region.attributes("aria-live")).toBe("polite"); + expect(region.attributes("role")).toBe("status"); + expect(region.text()).toBe(""); + await wait(400); + expect(region.text()).toBe(chipName()); + expect(region.text()).toContain("templates"); + }); + + it("does not re-announce when the same denials render again", async () => { + await wait(400); + const changes: MutationRecord[] = []; + const observer = new MutationObserver((records) => changes.push(...records)); + observer.observe(announcer().element, { childList: true, characterData: true, subtree: true }); + state.entries.value = [viewDenial("templates", "list", ["GET /api/default/templates"])]; + await flushPromises(); + await wait(400); + observer.disconnect(); + expect(changes).toHaveLength(0); + }); + + it("announces again when the denials change", async () => { + await wait(400); + state.entries.value = [ + viewDenial("templates", "list", ["GET /api/default/templates"]), + viewDenial("destinations", "list", ["GET /api/default/destinations"]), + ]; + await flushPromises(); + expect(announcer().text()).toBe(chipName()); + expect(announcer().text()).not.toContain("templates"); + }); + + it("asks for an administrator, naming no permission, for a read no role grant lifts", async () => { + state.entries.value = [ + { + resource: { + key: "_unnamed/synthetics", + labelKey: "accessBoundary.resourcesInline.unnamed", + path: "/api/default/synthetics/variables", + grantable: false, + }, + permission: "list", + requests: ["GET /api/default/synthetics/variables"], + }, + ]; + await flushPromises(); + await wrapper!.find('[data-test="access-header-chip"]').trigger("click"); + await flushPromises(); + const card = document.querySelector('[data-test="access-header-popover"]')!; + expect(card.querySelector('[data-test="access-denied-title"]')?.textContent).toBe( + "You can't view this resource", + ); + expect(card.querySelector('[data-test="access-denied-ask-admin"]')?.textContent).toBe( + "Ask your administrator for access.", + ); + expect(card.textContent).not.toContain("permission"); + }); + + it("is no taller than the title it sits beside below lg", async () => { + wrapper!.unmount(); + screen.lgUp = false; + wrapper = mount(OAccessHeaderChip, { attachTo: document.body, global: { plugins: [i18n] } }); + const chip = wrapper.find('[data-test="access-header-chip"]'); + expect(chip.classes()).toContain("h-6"); + }); +}); diff --git a/web/src/components/access/OAccessHeaderChip.vue b/web/src/components/access/OAccessHeaderChip.vue index 2cdbd7b502..14f42ab425 100644 --- a/web/src/components/access/OAccessHeaderChip.vue +++ b/web/src/components/access/OAccessHeaderChip.vue @@ -30,13 +30,13 @@ along with this program. If not, see . . + {{ + announcement + }} diff --git a/web/src/components/alertLibrary/LibraryDrawer.vue b/web/src/components/alertLibrary/LibraryDrawer.vue index 2ff5641e05..1a57a5c698 100644 --- a/web/src/components/alertLibrary/LibraryDrawer.vue +++ b/web/src/components/alertLibrary/LibraryDrawer.vue @@ -240,19 +240,16 @@ import { computed, nextTick, ref, watch } from "vue"; import AlertQueryPreview from "@/components/alerts/AlertQueryPreview.vue"; import { ANOMALY_DIRECTION_KEYS } from "@/components/anomaly_detection/steps/AnomalyDetectionConfig.schema"; import PreviewAlert from "@/components/alerts/PreviewAlert.vue"; -import { useAlertCreation } from "@/composables/alerts/useAlertCreation"; import { useAlertLibrary } from "@/composables/alerts/useAlertLibrary"; import OTag from "@/lib/core/Badge/OTag.vue"; import OButton from "@/lib/core/Button/OButton.vue"; import OIcon from "@/lib/core/Icon/OIcon.vue"; import OBanner from "@/lib/feedback/Banner/OBanner.vue"; import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue"; -import { toast } from "@/lib/feedback/Toast/useToast"; import ODrawer from "@/lib/overlay/Drawer/ODrawer.vue"; import type { AlertLibraryEntry, AlertLibraryFile, StreamDataState } from "@/types/alertLibrary"; import { formatDistanceToNowStrict } from "date-fns"; import { raw, useI18nTyped, type I18nKey } from "@/types/i18n"; -import { buildPrefillFromLibrary } from "@/utils/alerts/prefill/fromLibrary"; import { alertTypeOf, @@ -262,7 +259,8 @@ import { severityBadgeValue, severityLabel, } from "./libraryFacets"; -import { readTunables, applyTunables } from "./libraryTunables"; +import { readTunables, applyTunables, fileForInstall } from "./libraryTunables"; +import { useLibraryCustomize } from "./useLibraryCustomize"; const props = defineProps<{ open: boolean; @@ -284,7 +282,7 @@ const emit = defineEmits<{ const { t } = useI18nTyped(); const { loadAlertFile } = useAlertLibrary(); -const { openAlertCreation } = useAlertCreation(); +const { customizeInEditor } = useLibraryCustomize(); const file = ref(null); const isLoading = ref(false); @@ -548,27 +546,13 @@ watch( ); // ── actions ──────────────────────────────────────────────────────────────── -const customize = () => { +const customize = async () => { if (!props.entry || !file.value) return; - - const opened = openAlertCreation( - buildPrefillFromLibrary({ entry: props.entry, file: file.value }), - ); - if (!opened) { - toast({ variant: "error", message: t("alert_library.drawer.customizeFailed") }); - return; - } - emit("update:open", false); + if (await customizeInEditor(props.entry, file.value)) emit("update:open", false); }; const install = () => { if (!props.entry || !file.value) return; - // The threshold floors would write a trigger_condition into a body that must not have one. - if (isAnomaly.value) { - emit("install", { entry: props.entry, file: file.value }); - return; - } - if (!tunedFile.value) return; - emit("install", { entry: props.entry, file: tunedFile.value }); + emit("install", { entry: props.entry, file: fileForInstall(props.entry, file.value) }); }; diff --git a/web/src/components/alertLibrary/LibraryInstallDialog.spec.ts b/web/src/components/alertLibrary/LibraryInstallDialog.spec.ts index 722ecd9e6d..edac864586 100644 --- a/web/src/components/alertLibrary/LibraryInstallDialog.spec.ts +++ b/web/src/components/alertLibrary/LibraryInstallDialog.spec.ts @@ -27,28 +27,16 @@ import { anomalyKeys } from "@/services/anomaly_detection.querykeys"; const mocks = vi.hoisted(() => ({ loadAlertFile: vi.fn(), - listDestinations: vi.fn(), createAlert: vi.fn(), createAnomaly: vi.fn(), getFoldersListByType: vi.fn(), - validateCredentials: vi.fn(() => ({ isValid: true, errors: {} })), - testDestination: vi.fn(), - createDestination: vi.fn(), - updateDestination: vi.fn(), - fetchSystemTemplates: vi.fn(), toast: vi.fn(), - resolveRoute: vi.fn(), })); // Every partial mock below spreads the REAL module and overrides only what the // test drives. A hand-listed subset silently removes the other members, so an // implementation that touches one dies at mount and reads as a component bug // rather than as the missing test double it is. -vi.mock("vue-router", async () => { - const actual = await vi.importActual>("vue-router"); - return { ...actual, useRouter: () => ({ resolve: mocks.resolveRoute }) }; -}); - vi.mock("@/composables/alerts/useAlertLibrary", async () => { const actual = await vi.importActual>( "@/composables/alerts/useAlertLibrary", @@ -62,13 +50,6 @@ vi.mock("@/composables/alerts/useAlertLibrary", async () => { }; }); -vi.mock("@/services/alert_destination", async () => { - const actual = await vi.importActual<{ default: Record }>( - "@/services/alert_destination", - ); - return { ...actual, default: { ...actual.default, list: mocks.listDestinations } }; -}); - vi.mock("@/services/alerts", async () => { const actual = await vi.importActual<{ default: Record }>("@/services/alerts"); return { ...actual, default: { ...actual.default, create_by_alert_id: mocks.createAlert } }; @@ -86,26 +67,6 @@ vi.mock("@/utils/commons", async () => { return { ...actual, getFoldersListByType: mocks.getFoldersListByType }; }); -// Safe to call the real factory: `fetchSystemTemplates` is only ever invoked -// from inside the other methods, never at construction, so nothing reaches the -// network just by building the composable. -vi.mock("@/composables/usePrebuiltDestinations", async () => { - const actual = await vi.importActual>( - "@/composables/usePrebuiltDestinations", - ); - return { - ...actual, - usePrebuiltDestinations: () => ({ - ...(actual.usePrebuiltDestinations as () => Record)(), - fetchSystemTemplates: mocks.fetchSystemTemplates, - validateCredentials: mocks.validateCredentials, - testDestination: mocks.testDestination, - createDestination: mocks.createDestination, - updateDestination: mocks.updateDestination, - }), - }; -}); - vi.mock("@/lib/feedback/Toast/useToast", () => ({ toast: mocks.toast })); vi.mock("@/services/product_analytics", () => ({ default: { track: vi.fn() } })); @@ -121,18 +82,6 @@ const ODialogStub = { template: '
', }; -const OSelectStub = { - name: "OSelect", - props: ["modelValue", "options", "label", "size", "placeholder", "errorMessage"], - emits: ["update:modelValue"], - template: - '", -}; - const OInputStub = { name: "OInput", props: [ @@ -170,20 +119,11 @@ const SelectFolderStub = { template: '
', }; -const PrebuiltSelectorStub = { - name: "PrebuiltDestinationSelector", - props: ["modelValue"], - emits: ["update:modelValue", "select"], - template: '
', -}; - const stubs = { ODialog: ODialogStub, - OSelect: OSelectStub, OInput: OInputStub, OCheckbox: OCheckboxStub, SelectFolderDropDown: SelectFolderStub, - PrebuiltDestinationSelector: PrebuiltSelectorStub, }; // ── fixtures ─────────────────────────────────────────────────────────────── @@ -241,7 +181,6 @@ const mountDialog = async (props: Record = {}) => { const wrapper = mount(LibraryInstallDialog, { props: { open: true, - entries: entries(), preselect: [], seed: { entry: seedEntry(), file: libraryFile("pod-oom-killed") }, ...props, @@ -257,26 +196,21 @@ const click = async (wrapper: any, test: string) => { await flushPromises(); }; -/** Walk Destination → Alerts → Folder → Tune → Install with the defaults. */ +/** The gallery's three-alert selection, with no drawer seed — the batch cases. */ +const mountBatch = (props: Record = {}) => + mountDialog({ seed: null, preselect: entries(), ...props }); + +/** Walk Folder → Tune → Add with the defaults; an anomaly-only batch has no Tune. */ const advanceToInstall = async (wrapper: any) => { - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await flushPromises(); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); + for (let guard = 0; guard < 3; guard += 1) { + if (wrapper.find('[data-test="alert-library-install-run"]').exists()) return; + await click(wrapper, "alert-library-install-next"); + } }; -/** Same walk, but installing all three — the batch cases. */ -const advanceAllToInstall = async (wrapper: any) => { - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await flushPromises(); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-select-all"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); -}; +/** Step titles as the stepper shows them, in order. */ +const stepTitles = (wrapper: any): string[] => + wrapper.findAllComponents({ name: "OStep" }).map((step: any) => String(step.props("title"))); /** Per-alert outcome as the results list reports it. */ const statusOf = (wrapper: any, id: string) => @@ -301,8 +235,6 @@ const drain = async (releases: Array<(value: unknown) => void>) => { const payloadFor = (name: string) => mocks.createAlert.mock.calls.find((call) => call[1].name === name)?.[1] as Record; -let windowOpen: ReturnType; - describe("LibraryInstallDialog", () => { beforeEach(() => { // Reset OUR mocks only. `clearAllMocks` cannot drain an unconsumed `…Once` @@ -313,290 +245,108 @@ describe("LibraryInstallDialog", () => { // strips their implementations for the rest of the file. Object.values(mocks).forEach((mock) => mock.mockReset()); vi.mocked(analytics.track).mockClear(); - mocks.listDestinations.mockResolvedValue({ - data: [{ name: "ops-slack" }, { name: "oncall-pagerduty" }], - }); mocks.loadAlertFile.mockImplementation((e: AlertLibraryEntry) => Promise.resolve(libraryFile(e.name)), ); mocks.createAlert.mockResolvedValue({ data: {} }); mocks.getFoldersListByType.mockResolvedValue([]); - mocks.validateCredentials.mockReturnValue({ isValid: true, errors: {} }); - mocks.createDestination.mockResolvedValue(undefined); - mocks.testDestination.mockResolvedValue({ success: true }); - mocks.resolveRoute.mockReturnValue({ href: "/web/alerts/destinations?action=add" }); - windowOpen = vi.spyOn(window, "open").mockReturnValue(null); }); - afterEach(() => { - windowOpen.mockRestore(); - }); - - describe("destination step", () => { - it("offers the destinations this org actually has", async () => { + describe("steps", () => { + it("asks only Folder, Tune and Add for a batch with a threshold alert", async () => { const wrapper = await mountDialog(); - expect(mocks.listDestinations).toHaveBeenCalledWith( - expect.objectContaining({ org_identifier: "default", module: "alert" }), - ); - // Membership, not order: sorting or grouping the picker is a display - // choice, and pinning the API's order forbids it for no reason. - const options = wrapper - .find('[data-test="alert-library-install-destination"]') - .attributes("data-options") - ?.split(","); - expect(options).toHaveLength(2); - expect(options).toEqual(expect.arrayContaining(["ops-slack", "oncall-pagerduty"])); + expect(stepTitles(wrapper)).toEqual(["Folder", "Tune", "Add"]); + expect(wrapper.find('[data-test="alert-library-install-folder-step"]').exists()).toBe(true); }); - it("advances without a destination, which is optional", async () => { + it("never asks for a destination", async () => { const wrapper = await mountDialog(); - expect( - wrapper.find('[data-test="alert-library-install-next"]').attributes("disabled"), - ).toBeUndefined(); + expect(wrapper.text()).not.toContain("Destination"); + expect(wrapper.find('[data-test="alert-library-install-destination"]').exists()).toBe(false); + await advanceToInstall(wrapper); + expect(wrapper.find('[data-test="alert-library-install-summary"]').text()).not.toContain( + "Destination", + ); }); - it("installs with no destination when none is chosen", async () => { - const wrapper = await mountDialog(); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); + it("offers no per-alert checklist — the page already chose the alerts", async () => { + const wrapper = await mountBatch(); + expect(wrapper.find('[data-test^="alert-library-install-alert-"]').exists()).toBe(false); + await advanceToInstall(wrapper); + expect(wrapper.find('[data-test="alert-library-install-run"]').text()).toContain("3"); + }); - expect(wrapper.find('[data-test="alert-library-install-summary"]').text()).toContain( - (i18n.global.t as any)("common.none"), - ); + it("installs every alert passed in, with no destination", async () => { + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); - expect(mocks.createAlert).toHaveBeenCalledWith( - "default", - expect.objectContaining({ destinations: [] }), - "default", - ); + expect(mocks.createAlert.mock.calls.map((call) => call[1].name)).toEqual([ + "pod-oom-killed", + "node-disk-pressure", + "cert-expiring", + ]); + for (const call of mocks.createAlert.mock.calls) expect(call[1].destinations).toEqual([]); }); - it("does not mistake a failed request for an org with no destinations", async () => { - // A transient GET failure says nothing about how many destinations exist. - // Forcing create mode on it TRAPS the user: the way back out is hidden - // exactly when the list is empty, and Back is hidden on step 1 — so an - // org with fifty destinations could only create a duplicate or quit. - mocks.listDestinations.mockRejectedValue({ response: { status: 500 } }); - const wrapper = await mountDialog(); - - expect(wrapper.find('[data-test="alert-library-install-destinations-failed"]').exists()).toBe( - true, - ); - expect(wrapper.find('[data-test="alert-library-install-destination"]').exists()).toBe(true); - // NOT the empty-org state: the list is unknown, not known to be empty. - expect(wrapper.find('[data-test="alert-library-install-destinations-empty"]').exists()).toBe( - false, - ); - // And a way forward either way. - expect(wrapper.find('[data-test="alert-library-install-destinations-retry"]').exists()).toBe( - true, - ); - expect(wrapper.find('[data-test="alert-library-install-open-destinations"]').exists()).toBe( - true, - ); - }); - - it("says so plainly when the org genuinely has no destinations", async () => { - // A legitimate first-run condition, not an error — and no longer - // something this dialog can fix, so it has to point somewhere real. - mocks.listDestinations.mockResolvedValue({ data: [] }); - const wrapper = await mountDialog(); - - expect(wrapper.find('[data-test="alert-library-install-destinations-empty"]').exists()).toBe( - true, - ); - expect(wrapper.find('[data-test="alert-library-install-destinations-failed"]').exists()).toBe( - false, - ); - expect(wrapper.find('[data-test="alert-library-install-open-destinations"]').exists()).toBe( - true, - ); - // The alerts install without one, so the wizard still moves on. - expect( - wrapper.find('[data-test="alert-library-install-next"]').attributes("disabled"), - ).toBeUndefined(); - }); - - it("routes to the Destinations page for every type, not just a custom one", async () => { - const wrapper = await mountDialog(); - await click(wrapper, "alert-library-install-open-destinations"); - - expect(mocks.resolveRoute).toHaveBeenCalledWith( - expect.objectContaining({ name: "alertDestinations" }), - ); - expect(windowOpen).toHaveBeenCalledWith( - "/web/alerts/destinations?action=add", - "_blank", - expect.stringContaining("noopener"), - ); - }); - - it("offers a retry that recovers the list", async () => { - mocks.listDestinations.mockRejectedValue({ response: { status: 500 } }); - const wrapper = await mountDialog(); - - mocks.listDestinations.mockResolvedValue({ data: [{ name: "ops-slack" }] }); - await click(wrapper, "alert-library-install-destinations-retry"); - - expect(wrapper.find('[data-test="alert-library-install-destinations-failed"]').exists()).toBe( - false, - ); - expect( - wrapper.find('[data-test="alert-library-install-destination"]').attributes("data-options"), - ).toBe("ops-slack"); - }); - }); - - describe("alerts step", () => { - it("arrives with the drawer's alert already chosen and the rest offered", async () => { - const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - - const checked = (id: string) => - ( - wrapper.find(`[data-test="alert-library-install-alert-${id}"]`) - .element as HTMLInputElement - ).checked; - expect(checked("k8s/pod-oom-killed")).toBe(true); - expect(checked("k8s/node-disk-pressure")).toBe(false); - expect(checked("k8s/cert-expiring")).toBe(false); - }); - - it("cannot continue with nothing selected", async () => { - const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - - await wrapper - .find('[data-test="alert-library-install-alert-k8s/pod-oom-killed"]') - .setValue(false); - await flushPromises(); - expect( - wrapper.find('[data-test="alert-library-install-next"]').attributes("disabled"), - ).toBeDefined(); - }); - - it("survives an empty candidate list without offering a dead Next", async () => { - const wrapper = await mountDialog({ entries: [], seed: null }); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - - expect(wrapper.find('[data-test="alert-library-install-alerts-step"]').exists()).toBe(true); - expect( - wrapper.find('[data-test="alert-library-install-next"]').attributes("disabled"), - ).toBeDefined(); - }); - - it("offers the seeded alert even when the gallery filter excludes it", async () => { - // Real: `entries` is the gallery's FILTERED view, and the user may have - // narrowed it after opening the drawer. Dropping the seed would silently - // install nothing, or install the wrong alert. - const wrapper = await mountDialog({ - entries: [secondEntry(), thirdEntry()], - seed: { entry: seedEntry(), file: libraryFile("pod-oom-killed") }, - }); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - - const seedRow = wrapper.find('[data-test="alert-library-install-alert-k8s/pod-oom-killed"]'); - expect(seedRow.exists()).toBe(true); - expect((seedRow.element as HTMLInputElement).checked).toBe(true); - - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); + it("installs the drawer's single alert, whatever else the gallery had selected", async () => { + const wrapper = await mountDialog({ preselect: [secondEntry(), thirdEntry()] }); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); expect(mocks.createAlert).toHaveBeenCalledTimes(1); expect(mocks.createAlert.mock.calls[0][1].name).toBe("pod-oom-killed"); }); - it("goes back to the destination step without losing the choice", async () => { - const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-back"); - - expect(wrapper.find('[data-test="alert-library-install-destination-step"]').exists()).toBe( - true, - ); + it("offers no run for an empty batch", async () => { + const wrapper = await mountDialog({ seed: null, preselect: [] }); + await advanceToInstall(wrapper); expect( - wrapper.find('[data-test="alert-library-install-destination"]').attributes("data-value"), - ).toBe("ops-slack"); + wrapper.find('[data-test="alert-library-install-run"]').attributes("disabled"), + ).toBeDefined(); }); - it("selects and clears every offered alert in one click", async () => { + it("goes back from Add to Tune to Folder", async () => { const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - - // Read the numbers as data, not as prose. `toContain("3")` was worse than - // useless — "0 of 3 selected" contains "3" — but exact text couples the - // test to the copy AND to source formatting, since VTU's `text()` trims - // the ends without collapsing interior whitespace, so a Prettier rewrap - // of the template would fail a correct component. - const count = () => wrapper.find('[data-test="alert-library-install-count"]'); - - await click(wrapper, "alert-library-install-select-all"); - expect(count().attributes("data-selected")).toBe("3"); - expect(count().attributes("data-total")).toBe("3"); - - await click(wrapper, "alert-library-install-clear"); - expect(count().attributes("data-selected")).toBe("0"); - expect(count().attributes("data-total")).toBe("3"); + await advanceToInstall(wrapper); + await click(wrapper, "alert-library-install-back"); + expect(wrapper.find('[data-test="alert-library-install-tune-step"]').exists()).toBe(true); + await click(wrapper, "alert-library-install-back"); + expect(wrapper.find('[data-test="alert-library-install-folder-step"]').exists()).toBe(true); }); }); describe("folder step", () => { - it("refreshes the alert folder list on reaching the step, not before", async () => { - const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - - // WHEN is the whole claim: SelectFolderDropDown refreshes itself only on - // `onActivated`, which a dialog never fires. Asserting only that it was - // called eventually would pass on a fetch at mount, which is the bug. + it("refreshes the alert folder list when the dialog opens, since Folder is the first step", async () => { + // SelectFolderDropDown refreshes itself only on `onActivated`, which a dialog never fires. + await mountDialog({ open: false }); expect(mocks.getFoldersListByType).not.toHaveBeenCalled(); - await click(wrapper, "alert-library-install-next"); + const wrapper = await mountDialog(); expect(mocks.getFoldersListByType).toHaveBeenCalledWith(expect.anything(), "alerts"); expect(wrapper.find('[data-test="folder-picker"]').attributes("data-type")).toBe("alerts"); }); it("installs into the folder the picker reports", async () => { const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - wrapper .findComponent({ name: "SelectFolderDropDown" }) .vm.$emit("folder-selected", { label: "Kubernetes", value: "folder-42" }); await flushPromises(); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); expect(mocks.createAlert).toHaveBeenCalledWith("default", expect.anything(), "folder-42"); }); it("still lets the batch install when the folder list cannot be read", async () => { - // As plausible as the destination 403 already covered. "default" always - // exists, so a failed refresh must degrade to it rather than trap the run. + // "default" always exists, so a failed refresh must degrade to it rather than trap the run. mocks.getFoldersListByType.mockRejectedValue({ response: { status: 403 } }); const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - expect(wrapper.find('[data-test="alert-library-install-folder-step"]').exists()).toBe(true); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); + expect(wrapper.find('[data-test="folder-picker"]').exists()).toBe(true); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); expect(mocks.createAlert).toHaveBeenCalledWith("default", expect.anything(), "default"); @@ -615,10 +365,8 @@ describe("LibraryInstallDialog", () => { await click(wrapper, "alert-library-install-run"); const payload = mocks.createAlert.mock.calls[0][1] as Record; - // Wizard-supplied: the chosen destination, the folder, and the store - // identity/timezone. `owner: undefined` serialises away silently, so - // nothing else in either spec would notice these were never read. - expect(payload.destinations).toEqual(["ops-slack"]); + // `owner: undefined` serialises away silently, so only this read-back proves the wizard fed it. + expect(payload.destinations).toEqual([]); expect(payload.folder_id).toBe("default"); expect(mocks.createAlert.mock.calls[0][2]).toBe("default"); expect(payload.owner).toBe("example@gmail.com"); @@ -629,8 +377,8 @@ describe("LibraryInstallDialog", () => { }); it("builds each alert from its own entry, not from the seed's", async () => { - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); expect(payloadFor("pod-oom-killed").context_attributes).toMatchObject({ @@ -658,25 +406,38 @@ describe("LibraryInstallDialog", () => { expect(payload.trigger_condition.silence).toBe(45); }); - it("fetches the file for an alert the drawer never opened, and only those", async () => { - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + it("fetches the file of every alert in a gallery batch, which no drawer opened", async () => { + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); - expect(mocks.loadAlertFile).toHaveBeenCalledTimes(2); expect(mocks.loadAlertFile.mock.calls.map((call) => call[0].id)).toEqual([ + "k8s/pod-oom-killed", "k8s/node-disk-pressure", "k8s/cert-expiring", ]); expect(mocks.createAlert).toHaveBeenCalledTimes(3); }); + it("floors a fetched batch file's zero threshold and period, as the card and drawer do", async () => { + mocks.loadAlertFile.mockImplementation((e: AlertLibraryEntry) => + Promise.resolve({ + ...libraryFile(e.name), + trigger_condition: { period: 0, operator: ">=", threshold: 0, frequency: 5, silence: 30 }, + }), + ); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); + await click(wrapper, "alert-library-install-run"); + + for (const name of ["pod-oom-killed", "node-disk-pressure", "cert-expiring"]) { + expect(payloadFor(name).trigger_condition.threshold).toBe(1); + expect(payloadFor(name).trigger_condition.period).toBe(1); + } + }); + it("applies the bulk-tuned pair to every alert in the batch", async () => { - const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-select-all"); - await click(wrapper, "alert-library-install-next"); + const wrapper = await mountBatch(); await click(wrapper, "alert-library-install-next"); await wrapper.find('[data-test="alert-library-install-tune-toggle"]').setValue(true); @@ -712,9 +473,6 @@ describe("LibraryInstallDialog", () => { // `Number("")` is 0, which silently turns "run every N minutes" into an // evaluation that never comes round. The drawer already solved this. const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); await click(wrapper, "alert-library-install-next"); await wrapper.find('[data-test="alert-library-install-tune-toggle"]').setValue(true); @@ -734,9 +492,6 @@ describe("LibraryInstallDialog", () => { it("keeps a silence of zero, which means 'never suppress'", async () => { const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); await click(wrapper, "alert-library-install-next"); await wrapper.find('[data-test="alert-library-install-tune-toggle"]').setValue(true); @@ -769,8 +524,8 @@ describe("LibraryInstallDialog", () => { const releases: Array<(value: unknown) => void> = []; mocks.createAlert.mockImplementation(() => new Promise((resolve) => releases.push(resolve))); - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); expect(mocks.createAlert).toHaveBeenCalledTimes(1); @@ -803,8 +558,8 @@ describe("LibraryInstallDialog", () => { const releases: Array<(value: unknown) => void> = []; mocks.createAlert.mockImplementation(() => new Promise((resolve) => releases.push(resolve))); - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); expect(mocks.createAlert).toHaveBeenCalledTimes(1); @@ -827,8 +582,8 @@ describe("LibraryInstallDialog", () => { .mockRejectedValueOnce({ response: { data: { message: "alert name already exists" } } }) .mockResolvedValueOnce({ data: {} }); - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); // The third create is the whole point: it only happens if the second @@ -853,8 +608,8 @@ describe("LibraryInstallDialog", () => { .mockRejectedValueOnce({ response: { data: { message: "boom" } } }) .mockResolvedValueOnce({ data: {} }); - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); // Times(1) matters: `toHaveBeenCalledWith` scans EVERY call, so an @@ -865,18 +620,23 @@ describe("LibraryInstallDialog", () => { }); it("reports a file that could not be fetched as that alert's failure only", async () => { - mocks.loadAlertFile.mockRejectedValue(new Error("network down")); + mocks.loadAlertFile.mockImplementation((e: AlertLibraryEntry) => + e.name === "node-disk-pressure" + ? Promise.reject(new Error("network down")) + : Promise.resolve(libraryFile(e.name)), + ); - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); - // The seeded alert still installs; the two that needed a fetch do not. - expect(mocks.createAlert).toHaveBeenCalledTimes(1); - expect(mocks.createAlert.mock.calls[0][1].name).toBe("pod-oom-killed"); + expect(mocks.createAlert.mock.calls.map((call) => call[1].name)).toEqual([ + "pod-oom-killed", + "cert-expiring", + ]); expect(statusOf(wrapper, "k8s/pod-oom-killed")).toBe("installed"); expect(statusOf(wrapper, "k8s/node-disk-pressure")).toBe("failed"); - expect(statusOf(wrapper, "k8s/cert-expiring")).toBe("failed"); + expect(statusOf(wrapper, "k8s/cert-expiring")).toBe("installed"); }); it("retries only what failed, and keeps the successes reported", async () => { @@ -885,8 +645,8 @@ describe("LibraryInstallDialog", () => { .mockRejectedValueOnce({ response: { data: { message: "boom" } } }) .mockResolvedValueOnce({ data: {} }); - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); mocks.createAlert.mockClear(); @@ -908,8 +668,8 @@ describe("LibraryInstallDialog", () => { .mockRejectedValueOnce({ response: { data: { message: "boom" } } }) .mockResolvedValueOnce({ data: {} }); - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); const releases: Array<(value: unknown) => void> = []; @@ -937,8 +697,8 @@ describe("LibraryInstallDialog", () => { .mockRejectedValueOnce({ response: { data: { message: "boom" } } }) .mockResolvedValueOnce({ data: {} }); - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); const installed = wrapper.emitted("installed"); @@ -954,8 +714,8 @@ describe("LibraryInstallDialog", () => { .mockRejectedValueOnce({ response: { data: { message: "boom" } } }) .mockResolvedValueOnce({ data: {} }); - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); expect(analytics.track).toHaveBeenCalledTimes(1); @@ -968,8 +728,8 @@ describe("LibraryInstallDialog", () => { it("does not track library_alert_installed when every install fails", async () => { mocks.createAlert.mockRejectedValue({ response: { data: { message: "boom" } } }); - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); expect(analytics.track).not.toHaveBeenCalled(); @@ -1034,18 +794,9 @@ describe("LibraryInstallDialog", () => { it("installs a repeated manifest id exactly once", async () => { // The manifest is fetched content; a duplicated entry would otherwise be // walked twice and create the alert twice. - const wrapper = await mountDialog({ entries: [seedEntry(), seedEntry(), secondEntry()] }); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-select-all"); - - expect(wrapper.find('[data-test="alert-library-install-count"]').attributes("data-total")).toBe( - "2", - ); - - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); + const wrapper = await mountBatch({ preselect: [seedEntry(), seedEntry(), secondEntry()] }); + await advanceToInstall(wrapper); + expect(wrapper.find('[data-test="alert-library-install-run"]').text()).toContain("2"); await click(wrapper, "alert-library-install-run"); expect(mocks.createAlert).toHaveBeenCalledTimes(2); @@ -1065,11 +816,7 @@ describe("LibraryInstallDialog", () => { return Promise.resolve(file); }); - const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-select-all"); - await click(wrapper, "alert-library-install-next"); + const wrapper = await mountBatch(); await click(wrapper, "alert-library-install-next"); await click(wrapper, "alert-library-install-next"); await click(wrapper, "alert-library-install-run"); @@ -1093,8 +840,8 @@ describe("LibraryInstallDialog", () => { .mockRejectedValueOnce({ response: { data: { message: "boom" } } }) .mockResolvedValueOnce({ data: {} }); - const wrapper = await mountDialog(); - await advanceAllToInstall(wrapper); + const wrapper = await mountBatch(); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); mocks.createAlert.mockResolvedValue({ data: {} }); @@ -1108,17 +855,14 @@ describe("LibraryInstallDialog", () => { it("starts from the first step again when reopened", async () => { const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); await click(wrapper, "alert-library-install-next"); - expect(wrapper.find('[data-test="alert-library-install-alerts-step"]').exists()).toBe(true); + expect(wrapper.find('[data-test="alert-library-install-tune-step"]').exists()).toBe(true); await wrapper.setProps({ open: false }); await wrapper.setProps({ open: true }); await flushPromises(); - expect(wrapper.find('[data-test="alert-library-install-destination-step"]').exists()).toBe( - true, - ); + expect(wrapper.find('[data-test="alert-library-install-folder-step"]').exists()).toBe(true); }); it("forgets the previous run when reopened", async () => { @@ -1134,27 +878,16 @@ describe("LibraryInstallDialog", () => { expect(wrapper.findAll('[data-test^="alert-library-install-result-"]')).toHaveLength(0); }); - it("reseeds the selection from the alert the drawer handed over", async () => { + it("installs the alert the drawer handed over on the latest open", async () => { const wrapper = await mountDialog(); await wrapper.setProps({ open: false }); await wrapper.setProps({ seed: { entry: thirdEntry(), file: libraryFile("cert-expiring") } }); await wrapper.setProps({ open: true }); await flushPromises(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - expect( - ( - wrapper.find('[data-test="alert-library-install-alert-k8s/cert-expiring"]') - .element as HTMLInputElement - ).checked, - ).toBe(true); - expect( - ( - wrapper.find('[data-test="alert-library-install-alert-k8s/pod-oom-killed"]') - .element as HTMLInputElement - ).checked, - ).toBe(false); + await advanceToInstall(wrapper); + await click(wrapper, "alert-library-install-run"); + expect(mocks.createAlert.mock.calls.map((call) => call[1].name)).toEqual(["cert-expiring"]); }); // The stub above renders the panel inline and has no close button, which makes @@ -1182,35 +915,6 @@ describe("LibraryInstallDialog", () => { document.body.innerHTML = ""; }); - it("gives every alert checkbox an accessible name", async () => { - // OCheckbox names itself from `label` alone. Without it a screen-reader - // user hears "checkbox, unchecked" once per alert with no way to tell - // which one they are about to install. - const checkboxStubs = { ...realStubs } as Record; - checkboxStubs.ODialog = ODialogStub; - delete checkboxStubs.OCheckbox; - - wrapper = mount(LibraryInstallDialog, { - props: { - open: true, - entries: entries(), - preselect: [], - seed: { entry: seedEntry(), file: libraryFile("pod-oom-killed") }, - }, - global: { plugins: [i18n, store], stubs: checkboxStubs }, - }); - await flushPromises(); - - const select = wrapper.find('[data-test="alert-library-install-destination"]'); - await select.setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - - const row = wrapper.find('[data-test="alert-library-install-alert-k8s/pod-oom-killed"]'); - expect(row.exists()).toBe(true); - expect(row.text()).toContain("Pod OOM Killed"); - expect(row.find('[role="checkbox"]').exists()).toBe(true); - }); - it("takes the close button away while creates are in flight", async () => { let release: (value: unknown) => void = () => {}; mocks.createAlert.mockReturnValue( @@ -1222,7 +926,6 @@ describe("LibraryInstallDialog", () => { wrapper = mount(LibraryInstallDialog, { props: { open: true, - entries: entries(), preselect: [], seed: { entry: seedEntry(), file: libraryFile("pod-oom-killed") }, }, @@ -1233,13 +936,6 @@ describe("LibraryInstallDialog", () => { expect(inBody("o-dialog-close-btn")).not.toBeNull(); - const select = inBody("alert-library-install-destination") as HTMLSelectElement; - select.value = "ops-slack"; - select.dispatchEvent(new Event("change")); - await flushPromises(); - - await clickInBody("alert-library-install-next"); - await clickInBody("alert-library-install-next"); await clickInBody("alert-library-install-next"); await clickInBody("alert-library-install-next"); await clickInBody("alert-library-install-run"); @@ -1255,18 +951,14 @@ describe("LibraryInstallDialog", () => { }); }); - it("loads no destinations while closed", async () => { + it("loads nothing while closed", async () => { await mountDialog({ open: false }); - expect(mocks.listDestinations).not.toHaveBeenCalled(); expect(mocks.loadAlertFile).not.toHaveBeenCalled(); expect(mocks.getFoldersListByType).not.toHaveBeenCalled(); }); describe("the tune step's hint", () => { const hintOf = async () => { const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); await click(wrapper, "alert-library-install-next"); return wrapper.get('[data-test="alert-library-install-tune-hint"]').text(); }; @@ -1281,9 +973,6 @@ describe("LibraryInstallDialog", () => { it("takes those names from the field labels, so they cannot drift apart", async () => { const wrapper = await mountDialog(); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); await click(wrapper, "alert-library-install-next"); await wrapper.find('[data-test="alert-library-install-tune-toggle"]').setValue(true); await flushPromises(); @@ -1301,132 +990,42 @@ describe("LibraryInstallDialog", () => { // ── preselection from the gallery ───────────────────────────────────────── describe("preselect", () => { - const ticked = (wrapper: any) => - wrapper - .findAll('[data-test^="alert-library-install-alert-"]') - .filter((box: any) => box.element.checked) - .map((box: any) => - box.attributes("data-test")!.replace("alert-library-install-alert-", ""), - ); - - it("ticks exactly the alerts the gallery handed over", async () => { - const wrapper = await mountDialog({ - seed: null, - preselect: [seedEntry(), thirdEntry()], - }); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - - expect(ticked(wrapper).sort()).toEqual(["k8s/cert-expiring", "k8s/pod-oom-killed"]); - expect( - wrapper.find('[data-test="alert-library-install-count"]').attributes("data-selected"), - ).toBe("2"); - }); - - it("offers a preselected alert the current filters exclude", async () => { - // `entries` is the gallery's FILTERED view. A batch built before the user - // narrowed it would otherwise report a count for alerts not in the list. - const offView = entry({ + const offView = () => + entry({ id: "kafka/consumer-lag", name: "consumer-lag", pack: "kafka", title: "Consumer Lag Growing", content_hash: "hash-9", }); - const wrapper = await mountDialog({ seed: null, preselect: [offView] }); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - expect( - wrapper.find('[data-test="alert-library-install-alert-kafka/consumer-lag"]').exists(), - ).toBe(true); - expect(ticked(wrapper)).toEqual(["kafka/consumer-lag"]); - expect( - wrapper.find('[data-test="alert-library-install-count"]').attributes("data-total"), - ).toBe("4"); + it("installs exactly the alerts the gallery handed over", async () => { + const wrapper = await mountDialog({ seed: null, preselect: [seedEntry(), thirdEntry()] }); + await advanceToInstall(wrapper); + await click(wrapper, "alert-library-install-run"); + + expect(mocks.createAlert.mock.calls.map((call) => call[1].name)).toEqual([ + "pod-oom-killed", + "cert-expiring", + ]); }); - it("creates the off-view alert, not just lists it", async () => { - const offView = entry({ - id: "kafka/consumer-lag", - name: "consumer-lag", - pack: "kafka", - title: "Consumer Lag Growing", - content_hash: "hash-9", - }); + it("creates an alert the gallery's current filters hide", async () => { + // The batch outlives the filters: it was frozen when the user pressed Add. mocks.loadAlertFile.mockResolvedValue(libraryFile("consumer-lag")); - mocks.createAlert.mockResolvedValue({ data: {} }); - - const wrapper = await mountDialog({ seed: null, preselect: [offView] }); + const wrapper = await mountDialog({ seed: null, preselect: [offView()] }); await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); - await flushPromises(); expect(statusOf(wrapper, "kafka/consumer-lag")).toBe("installed"); }); - it("lists the seed first, then the off-view batch, then the visible set", async () => { - const offView = entry({ id: "kafka/consumer-lag", name: "consumer-lag", pack: "kafka" }); - const wrapper = await mountDialog({ preselect: [offView] }); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - - const order = wrapper - .findAll('[data-test^="alert-library-install-alert-"]') - .map((b: any) => b.attributes("data-test")!.replace("alert-library-install-alert-", "")); - expect(order[0]).toBe("k8s/pod-oom-killed"); - expect(order[1]).toBe("kafka/consumer-lag"); - }); - - it("lets the drawer's seed win — one alert, whatever the gallery had selected", async () => { - const wrapper = await mountDialog({ preselect: [secondEntry(), thirdEntry()] }); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - - expect(ticked(wrapper)).toEqual(["k8s/pod-oom-killed"]); - }); - - it("scopes an untick to this run — it tells the gallery nothing", async () => { - // The gallery selection is the user's shelf; only a successful add takes - // an alert off it. A modal editing that state behind its own backdrop is - // the two-selection-models failure this design exists to avoid. + it("tells the gallery nothing until an alert is actually added", async () => { const wrapper = await mountDialog({ seed: null, preselect: [seedEntry(), secondEntry()] }); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - - await wrapper - .find('[data-test="alert-library-install-alert-k8s/pod-oom-killed"]') - .setValue(false); - await flushPromises(); + await advanceToInstall(wrapper); expect(Object.keys(wrapper.emitted())).not.toContain("installed"); expect(Object.keys(wrapper.emitted())).not.toContain("update:preselect"); - // The row is off for this run, and the dialog said nothing to anyone. - expect( - wrapper.find('[data-test="alert-library-install-count"]').attributes("data-selected"), - ).toBe("1"); - }); - - it("still widens a preselected batch from step 2", async () => { - const wrapper = await mountDialog({ seed: null, preselect: [seedEntry()] }); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-select-all"); - - expect( - wrapper.find('[data-test="alert-library-install-count"]').attributes("data-selected"), - ).toBe("3"); - }); - - it("never lists an alert twice when the gallery preselects a visible one", async () => { - const wrapper = await mountDialog({ seed: null, preselect: [secondEntry()] }); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - - const rows = wrapper.findAll( - '[data-test="alert-library-install-alert-k8s/node-disk-pressure"]', - ); - expect(rows).toHaveLength(1); }); }); @@ -1447,7 +1046,7 @@ describe("LibraryInstallDialog", () => { it("asks before a run big enough to reshape the org's alerting", async () => { // The catalog is ~1,242 alerts and select-all-in-view is one click, so // the largest batches must be deliberate rather than merely reachable. - const wrapper = await mountDialog({ seed: null, entries: many(51), preselect: many(51) }); + const wrapper = await mountDialog({ seed: null, preselect: many(51) }); await advanceToInstall(wrapper); const confirm = wrapper.find('[data-test="alert-library-install-confirm-large"]'); @@ -1463,7 +1062,7 @@ describe("LibraryInstallDialog", () => { }); it("stays out of the way for an ordinary batch", async () => { - const wrapper = await mountDialog({ seed: null, entries: many(50), preselect: many(50) }); + const wrapper = await mountDialog({ seed: null, preselect: many(50) }); await advanceToInstall(wrapper); expect(wrapper.find('[data-test="alert-library-install-confirm-large"]').exists()).toBe( @@ -1472,102 +1071,35 @@ describe("LibraryInstallDialog", () => { expect(runButton(wrapper).attributes("disabled")).toBeUndefined(); }); - it("re-arms the confirmation when a narrowed batch is widened again", async () => { - // An acknowledgement given at 51, kept through a dip to 50 and a climb - // back to 51, would let the largest run through unacknowledged. - const wrapper = await mountDialog({ seed: null, entries: many(51), preselect: many(51) }); - const row = '[data-test="alert-library-install-alert-k8s/bulk-0"]'; - const backToAlerts = async () => { - await click(wrapper, "alert-library-install-back"); - await click(wrapper, "alert-library-install-back"); - await click(wrapper, "alert-library-install-back"); - }; - const forwardToRun = async () => { - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - }; - + it("drops the acknowledgement when the batch changes under it", async () => { + const wrapper = await mountDialog({ seed: null, preselect: many(51) }); await advanceToInstall(wrapper); await wrapper.find('[data-test="alert-library-install-confirm-large"]').setValue(true); await flushPromises(); expect(runButton(wrapper).attributes("disabled")).toBeUndefined(); - await backToAlerts(); - await wrapper.find(row).setValue(false); + await wrapper.setProps({ preselect: many(52) }); await flushPromises(); - await forwardToRun(); - expect(wrapper.find('[data-test="alert-library-install-confirm-large"]').exists()).toBe( - false, - ); - - await backToAlerts(); - await wrapper.find(row).setValue(true); - await flushPromises(); - await forwardToRun(); - - const confirm = wrapper.find('[data-test="alert-library-install-confirm-large"]'); - expect(confirm.exists()).toBe(true); - expect((confirm.element as HTMLInputElement).checked).toBe(false); - expect(runButton(wrapper).attributes("disabled")).toBeDefined(); - }); - - it("drops the acknowledgement when the batch grows instead of shrinking", async () => { - // The dangerous path: acknowledge 51, go back, Select all — which is what - // B14 exists to stop — and return to a run button that never re-armed. - const wrapper = await mountDialog({ seed: null, entries: many(80), preselect: many(51) }); - await advanceToInstall(wrapper); - await wrapper.find('[data-test="alert-library-install-confirm-large"]').setValue(true); - await flushPromises(); - expect(runButton(wrapper).attributes("disabled")).toBeUndefined(); - - await click(wrapper, "alert-library-install-back"); - await click(wrapper, "alert-library-install-back"); - await click(wrapper, "alert-library-install-back"); - await click(wrapper, "alert-library-install-select-all"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - - const confirm = wrapper.find('[data-test="alert-library-install-confirm-large"]'); - expect((confirm.element as HTMLInputElement).checked).toBe(false); - expect(runButton(wrapper).attributes("disabled")).toBeDefined(); - }); - - it("states the folder and destination it is asking about", async () => { - const wrapper = await mountDialog({ seed: null, entries: many(51), preselect: many(51) }); - await advanceToInstall(wrapper); - - const label = wrapper - .find('[data-test="alert-library-install-confirm-large"]') - .attributes("data-label"); - expect(label).toContain("51"); - expect(label).toContain("ops-slack"); - }); - - it("drops the acknowledgement when the destination changes under it", async () => { - // The tick is a statement about a specific folder and destination — the - // two parameters that decide who gets paged 51 times. - const wrapper = await mountDialog({ seed: null, entries: many(51), preselect: many(51) }); - await advanceToInstall(wrapper); - await wrapper.find('[data-test="alert-library-install-confirm-large"]').setValue(true); - await flushPromises(); - expect(runButton(wrapper).attributes("disabled")).toBeUndefined(); - - for (let back = 0; back < 4; back += 1) await click(wrapper, "alert-library-install-back"); - await wrapper - .get('[data-test="alert-library-install-destination"]') - .setValue("oncall-pagerduty"); - await flushPromises(); - for (let next = 0; next < 4; next += 1) await click(wrapper, "alert-library-install-next"); const confirm = wrapper.get('[data-test="alert-library-install-confirm-large"]'); expect((confirm.element as HTMLInputElement).checked).toBe(false); expect(runButton(wrapper).attributes("disabled")).toBeDefined(); }); + it("states the count and folder it is asking about, and that none notify yet", async () => { + const wrapper = await mountDialog({ seed: null, preselect: many(51) }); + await advanceToInstall(wrapper); + + const label = wrapper + .find('[data-test="alert-library-install-confirm-large"]') + .attributes("data-label"); + expect(label).toContain("51"); + expect(label).toContain("default"); + expect(label).toContain("none of which notify"); + }); + it("drops the acknowledgement when the folder changes under it", async () => { - const wrapper = await mountDialog({ seed: null, entries: many(51), preselect: many(51) }); + const wrapper = await mountDialog({ seed: null, preselect: many(51) }); await advanceToInstall(wrapper); await wrapper.find('[data-test="alert-library-install-confirm-large"]').setValue(true); await flushPromises(); @@ -1587,7 +1119,7 @@ describe("LibraryInstallDialog", () => { }); it("forgets the acknowledgement when the dialog is reopened", async () => { - const wrapper = await mountDialog({ seed: null, entries: many(51), preselect: many(51) }); + const wrapper = await mountDialog({ seed: null, preselect: many(51) }); await advanceToInstall(wrapper); await wrapper.find('[data-test="alert-library-install-confirm-large"]').setValue(true); await flushPromises(); @@ -1602,15 +1134,11 @@ describe("LibraryInstallDialog", () => { expect(runButton(wrapper).attributes("disabled")).toBeDefined(); }); - it("lists every alert in a large batch — the count is not capped", async () => { - const wrapper = await mountDialog({ seed: null, entries: many(100), preselect: many(100) }); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); + it("counts every alert in a large batch — the count is not capped", async () => { + const wrapper = await mountDialog({ seed: null, preselect: many(100) }); + await advanceToInstall(wrapper); - expect(wrapper.findAll('[data-test^="alert-library-install-alert-"]')).toHaveLength(100); - expect( - wrapper.find('[data-test="alert-library-install-count"]').attributes("data-selected"), - ).toBe("100"); + expect(runButton(wrapper).text()).toContain("100"); }); }); @@ -1686,7 +1214,6 @@ describe("LibraryInstallDialog", () => { it("sends each kind of a mixed batch through its own create", async () => { const wrapper = await mountDialog({ seed: null, - entries: [seedEntry(), anomalyEntry()], preselect: [seedEntry(), anomalyEntry()], }); await advanceToInstall(wrapper); @@ -1699,7 +1226,7 @@ describe("LibraryInstallDialog", () => { expect(anomalyPayload()).toMatchObject({ alert_type: "anomaly_detection", enabled: true, - destinations: ["ops-slack"], + destinations: [], folder_id: "default", priority: 3, }); @@ -1717,11 +1244,8 @@ describe("LibraryInstallDialog", () => { const invalidate = vi.spyOn(queryClient, "invalidateQueries"); const wrapper = await mountDialog({ seed: { entry: anomalyEntry(), file: anomalyFile() }, - entries: [anomalyEntry()], }); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); expect(invalidate).toHaveBeenCalledWith({ queryKey: anomalyKeys.all("default") }); @@ -1732,22 +1256,16 @@ describe("LibraryInstallDialog", () => { it("drops anomaly alerts from the batch when the feature turns off mid-dialog", async () => { const wrapper = await mountDialog({ seed: null, - entries: [seedEntry(), anomalyEntry()], preselect: [seedEntry(), anomalyEntry()], }); - const count = () => - wrapper.find('[data-test="alert-library-install-count"]').attributes("data-selected"); - await wrapper.find('[data-test="alert-library-install-destination"]').setValue("ops-slack"); - await click(wrapper, "alert-library-install-next"); - expect(count()).toBe("2"); + const runLabel = () => wrapper.find('[data-test="alert-library-install-run"]').text(); + await advanceToInstall(wrapper); + expect(runLabel()).toContain("2"); setAccess("true", false); await flushPromises(); - expect(count()).toBe("1"); + expect(runLabel()).toContain("1"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); await click(wrapper, "alert-library-install-run"); expect(mocks.createAnomaly).not.toHaveBeenCalled(); expect(mocks.createAlert).toHaveBeenCalledTimes(1); @@ -1756,11 +1274,8 @@ describe("LibraryInstallDialog", () => { it("disables Run when the feature turns off under an anomaly-only batch", async () => { const wrapper = await mountDialog({ seed: { entry: anomalyEntry(), file: anomalyFile() }, - entries: [anomalyEntry()], }); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); + await advanceToInstall(wrapper); setAccess("true", false); await flushPromises(); @@ -1775,12 +1290,9 @@ describe("LibraryInstallDialog", () => { it("says the Tune step applies to threshold alerts only in a mixed batch", async () => { const wrapper = await mountDialog({ seed: null, - entries: [seedEntry(), anomalyEntry()], preselect: [seedEntry(), anomalyEntry()], }); await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); expect(wrapper.find('[data-test="alert-library-install-tune-threshold-only"]').exists()).toBe( true, @@ -1790,12 +1302,10 @@ describe("LibraryInstallDialog", () => { it("skips the Tune step for an anomaly-only batch, both ways", async () => { const wrapper = await mountDialog({ seed: { entry: anomalyEntry(), file: anomalyFile() }, - entries: [anomalyEntry()], }); expect(wrapper.find('[data-test="alert-library-install-tune-step"]').exists()).toBe(false); + expect(stepTitles(wrapper)).toEqual(["Folder", "Add"]); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); await click(wrapper, "alert-library-install-next"); expect(wrapper.find('[data-test="alert-library-install-run"]').exists()).toBe(true); expect(wrapper.find('[data-test="alert-library-install-summary"]').text()).not.toContain( @@ -1803,7 +1313,7 @@ describe("LibraryInstallDialog", () => { ); await click(wrapper, "alert-library-install-back"); - expect(wrapper.find('[data-test="alert-library-install-next"]').exists()).toBe(true); + expect(wrapper.find('[data-test="alert-library-install-folder-step"]').exists()).toBe(true); await click(wrapper, "alert-library-install-next"); expect(wrapper.find('[data-test="alert-library-install-run"]').exists()).toBe(true); }); @@ -1811,12 +1321,9 @@ describe("LibraryInstallDialog", () => { it("notes after the run that anomaly alerts wait for training", async () => { const wrapper = await mountDialog({ seed: { entry: anomalyEntry(), file: anomalyFile() }, - entries: [anomalyEntry()], }); expect(wrapper.find('[data-test="alert-library-install-anomaly-note"]').exists()).toBe(false); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); - await click(wrapper, "alert-library-install-next"); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); expect(wrapper.find('[data-test="alert-library-install-anomaly-note"]').exists()).toBe(true); @@ -1826,27 +1333,25 @@ describe("LibraryInstallDialog", () => { setAccess("false", true); const wrapper = await mountDialog({ seed: null, - entries: [seedEntry(), anomalyEntry()], preselect: [seedEntry(), anomalyEntry()], }); - await click(wrapper, "alert-library-install-next"); + await advanceToInstall(wrapper); + await click(wrapper, "alert-library-install-run"); + expect(mocks.createAnomaly).not.toHaveBeenCalled(); + expect(mocks.createAlert).toHaveBeenCalledTimes(1); expect( wrapper - .find('[data-test="alert-library-install-alert-apm/traces_server_error_ratio_anomaly"]') + .find('[data-test="alert-library-install-result-apm/traces_server_error_ratio_anomaly"]') .exists(), ).toBe(false); - await click(wrapper, "alert-library-install-select-all"); - expect( - wrapper.find('[data-test="alert-library-install-count"]').attributes("data-selected"), - ).toBe("1"); }); it("refuses to send a file declaring an anomaly alert when the feature is off", async () => { setAccess("true", false); mocks.loadAlertFile.mockResolvedValue(anomalyFile()); - const wrapper = await mountDialog({ seed: null, entries: [secondEntry()] }); - await advanceAllToInstall(wrapper); + const wrapper = await mountDialog({ seed: null, preselect: [secondEntry()] }); + await advanceToInstall(wrapper); await click(wrapper, "alert-library-install-run"); expect(mocks.createAnomaly).not.toHaveBeenCalled(); diff --git a/web/src/components/alertLibrary/LibraryInstallDialog.vue b/web/src/components/alertLibrary/LibraryInstallDialog.vue index e1da69b19e..568e340916 100644 --- a/web/src/components/alertLibrary/LibraryInstallDialog.vue +++ b/web/src/components/alertLibrary/LibraryInstallDialog.vue @@ -14,19 +14,7 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . --> - +