diff --git a/src/api/http/src/handler/http/router/mod.rs b/src/api/http/src/handler/http/router/mod.rs index 449135585d..956c248f42 100644 --- a/src/api/http/src/handler/http/router/mod.rs +++ b/src/api/http/src/handler/http/router/mod.rs @@ -32,7 +32,7 @@ use openobserve_api_management::request::cloud; use openobserve_api_management::request::profiling; use openobserve_api_management::request::{ alerts, authz, dashboards, folders, kv, model_pricing, organization, service_accounts, - short_url, sourcemaps, status, stream, users, + short_url, slos, sourcemaps, status, stream, users, }; use openobserve_api_pipelines::request::{enrichment_table, functions, pipeline, pipelines}; use openobserve_api_search::{promql, search, traces}; @@ -844,11 +844,25 @@ pub fn service_routes() -> Router { .route("/v2/{org_id}/reports/{report_id}/enable", patch(dashboards::reports::enable_report_v2)) .route("/v2/{org_id}/reports/{report_id}/trigger", put(dashboards::reports::trigger_report_v2)) - // TODO(slo): the SLO routes are deferred and deliberately absent — - // spelling them out even in a comment would make the enterprise - // coverage test demand ROUTE_PERMISSIONS entries for them, since it - // scans this file's text. The handlers still live in - // `request::slos`; restore both sides together. + // SLOs. Deliberately NOT enterprise-gated: nothing about SLO + // measurement is an enterprise capability, and the handlers already + // return 501 when ZO_SLO_ENABLED is false. Literal segments are + // registered before the {slo_id} catch-all, per the router's ordering + // rule. + .route( + "/{org_id}/slos", + get(slos::list_slos).post(slos::create_slo), + ) + // Before the {slo_id} catch-all, or "move" is parsed as an SLO id. + .route("/{org_id}/slos/move", post(slos::move_slos)) + .route("/{org_id}/slos/{slo_id}/enable", put(slos::enable_slo)) + .route("/{org_id}/slos/{slo_id}/groups", get(slos::get_slo_groups)) + .route( + "/{org_id}/slos/{slo_id}", + get(slos::get_slo) + .put(slos::update_slo) + .delete(slos::delete_slo), + ) // Folders (v2) .route("/v2/{org_id}/folders/{folder_type}", get(folders::list_folders).post(folders::create_folder)) diff --git a/src/api/http/src/handler/http/router/openapi.rs b/src/api/http/src/handler/http/router/openapi.rs index 8b10ae9e89..c4de932885 100644 --- a/src/api/http/src/handler/http/router/openapi.rs +++ b/src/api/http/src/handler/http/router/openapi.rs @@ -266,8 +266,14 @@ use crate::{ openobserve_api_management::request::alerts::deduplication::preview_semantic_groups_diff, openobserve_api_management::request::alerts::deduplication::save_semantic_groups, openobserve_api_management::request::alerts::dedup_stats::get_dedup_summary, - // TODO(slo): the SLO handlers are deferred along with their routes - // in `router/mod.rs`, so they are not documented here. + openobserve_api_management::request::slos::list_slos, + openobserve_api_management::request::slos::get_slo, + openobserve_api_management::request::slos::create_slo, + openobserve_api_management::request::slos::update_slo, + openobserve_api_management::request::slos::delete_slo, + openobserve_api_management::request::slos::enable_slo, + openobserve_api_management::request::slos::get_slo_groups, + openobserve_api_management::request::slos::move_slos, synthetics::list_synthetics, synthetics::create_synthetic, synthetics::get_synthetic, diff --git a/src/api/management/src/request/status/mod.rs b/src/api/management/src/request/status/mod.rs index 15c14c1eec..b4e18dccad 100644 --- a/src/api/management/src/request/status/mod.rs +++ b/src/api/management/src/request/status/mod.rs @@ -206,6 +206,10 @@ struct ConfigResponse<'a> { online_evals_enabled: bool, anomaly_detection_enabled: bool, synthetics_enabled: bool, + /// SLO measurement (`ZO_SLO_ENABLED`). Not enterprise-gated — the SLO APIs + /// answer 501 while it is off, so the UI uses this to hide the menu entry + /// rather than offer a page that cannot work. + slo_enabled: bool, enable_cross_linking: bool, show_fts_field_values: bool, search_inspector_enabled: bool, @@ -479,6 +483,7 @@ pub async fn zo_config() -> impl IntoResponse { online_evals_enabled, anomaly_detection_enabled, synthetics_enabled, + slo_enabled: cfg.slo.enabled, enable_cross_linking: cfg.common.enable_cross_linking, show_fts_field_values: cfg.common.show_fts_field_values, search_inspector_enabled: cfg.common.search_inspector_enabled, diff --git a/src/config/src/config.rs b/src/config/src/config.rs index 0f87dcfec7..b756faeb90 100644 --- a/src/config/src/config.rs +++ b/src/config/src/config.rs @@ -638,8 +638,10 @@ pub struct Config { /// Feature 5 — SLO measurement (`alerts_2.md` §6b). #[derive(Debug, Serialize, EnvConfig, Default)] pub struct Slo { - // TODO(slo): the SLO feature is deferred; default stays false (and the - // SLO menu entry is hidden in MainLayout.vue) until it ships. + // Development is in progress; the default stays false until the feature + // ships. The UI follows this flag rather than duplicating the decision: + // it is published as `slo_enabled` on /config and MainLayout.vue hides the + // SLO menu entry while it is off. #[env_config( name = "ZO_SLO_ENABLED", default = false, diff --git a/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js b/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js index e38ceae294..0e9cdf65cb 100644 --- a/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js +++ b/tests/ui-testing/pages/alertsPages/alertDestinationsPage.js @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { CommonActions } from '../commonActions'; +import { CommonActions, openNavFlyoutChild } from '../commonActions.js'; import { AlertsPage } from './alertsPage.js'; const testLogger = require('../../playwright-tests/utils/test-logger.js'); const { getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js'); @@ -10,9 +10,9 @@ export class AlertDestinationsPage { this.commonActions = new CommonActions(page); this.alertsPage = new AlertsPage(page); - // Navigation locators - this.settingsMenuItem = '[data-test="menu-link-/settings-item"]'; - this.destinationsTab = '[data-test="alert-destinations-tab"]'; + // Navigation locators. Destinations moved out of Settings into the + // Reliability nav group, so there is no settings tab to click — use + // openNavFlyoutChild(page, 'destinations'). this.destinationsListTitle = '[data-test="alert-destinations-list-title"]'; // Destination creation locators @@ -150,7 +150,7 @@ export class AlertDestinationsPage { // Try URL-based navigation first (more reliable than menu clicking) const baseUrl = process.env.ZO_BASE_URL || 'http://localhost:5080'; const orgIdentifier = process.env.ORGNAME || 'default'; - const destinationsUrl = `${baseUrl}/web/settings/alert_destinations?org_identifier=${orgIdentifier}`; + const destinationsUrl = `${baseUrl}/web/alert-destinations?org_identifier=${orgIdentifier}`; try { await this.page.goto(destinationsUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); @@ -169,13 +169,8 @@ export class AlertDestinationsPage { testLogger.warn('URL navigation to destinations failed, trying menu path', { error: navError.message }); } - // Fallback: Navigate via Settings menu - await this.page.locator(this.settingsMenuItem).waitFor({ state: 'visible', timeout: 15000 }); - await this.page.locator(this.settingsMenuItem).click(); - await this.page.waitForTimeout(2000); - - await this.page.locator(this.destinationsTab).waitFor({ state: 'visible', timeout: 15000 }); - await this.page.locator(this.destinationsTab).click(); + // Fallback: navigate via the Reliability nav group (it left Settings). + await openNavFlyoutChild(this.page, 'destinations'); await this.page.waitForTimeout(2000); // Wait for destinations page to load @@ -466,7 +461,7 @@ export class AlertDestinationsPage { // Navigate directly to the import destination page (bypasses import button click) const baseUrl = process.env.ZO_BASE_URL || 'http://localhost:5080'; const orgIdentifier = process.env.ORGNAME || 'default'; - const importUrl = `${baseUrl}/web/settings/alert_destinations?org_identifier=${orgIdentifier}&action=import`; + const importUrl = `${baseUrl}/web/alert-destinations?org_identifier=${orgIdentifier}&action=import`; try { await this.page.goto(importUrl, { waitUntil: 'domcontentloaded', timeout: 20000 }); @@ -505,7 +500,7 @@ export class AlertDestinationsPage { // Wait for the post-import navigation back to the destinations list (router.push fires // ~400ms after the success toast). This replaces a fixed waitForTimeout and ensures // the new destination row has actually been created before downstream verification. - await this.page.waitForURL(/\/alert_destinations(?!.*action=import)/, { timeout: 15000 }).catch(() => {}); + await this.page.waitForURL(/\/alert-destinations(?!.*action=import)/, { timeout: 15000 }).catch(() => {}); } /** diff --git a/tests/ui-testing/pages/alertsPages/alertTemplatesPage.js b/tests/ui-testing/pages/alertsPages/alertTemplatesPage.js index 8378a5dfa9..790a9972fc 100644 --- a/tests/ui-testing/pages/alertsPages/alertTemplatesPage.js +++ b/tests/ui-testing/pages/alertsPages/alertTemplatesPage.js @@ -2,6 +2,7 @@ import { expect, test } from '@playwright/test'; import { test as base } from '@playwright/test'; import fs from 'fs'; import { AlertDestinationsPage } from './alertDestinationsPage.js'; +import { openNavFlyoutChild } from '../commonActions.js'; const testLogger = require('../../playwright-tests/utils/test-logger.js'); export class AlertTemplatesPage { @@ -9,10 +10,11 @@ export class AlertTemplatesPage { this.page = page; this.alertDestinationsPage = new AlertDestinationsPage(page); - // Navigation locators - this.settingsMenuItem = '[data-test="menu-link-/settings-item"]'; - this.templatesTab = '[data-test="alert-templates-tab"]'; - + // Navigation: Templates moved out of Settings into the Reliability nav + // group, so there is no settings tab to click — use + // openNavFlyoutChild(page, 'templates'). The list table selector lives + // on `templateTable` below. + // Template creation locators this.addTemplateButton = '[data-test="template-list-add-btn"]'; // OInput wrapper (use for visibility/state assertions); inner native input gets `-field` suffix @@ -74,16 +76,16 @@ export class AlertTemplatesPage { // Try URL-based navigation first (more reliable than menu clicking) const baseUrl = process.env.ZO_BASE_URL || 'http://localhost:5080'; const orgIdentifier = process.env.ORGNAME || 'default'; - const templatesUrl = `${baseUrl}/web/settings/templates?org_identifier=${orgIdentifier}`; + const templatesUrl = `${baseUrl}/web/alert-templates?org_identifier=${orgIdentifier}`; try { await this.page.goto(templatesUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); await this.page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => {}); await this.page.waitForTimeout(2000); - // Check if templates page loaded (look for templates tab content or add button) + // Check if templates page loaded (add button or the list table) const addBtn = this.page.locator(this.addTemplateButton); - const templatesContent = this.page.locator('[data-test="alert-templates-tab"], [class*="template"]').first(); + const templatesContent = this.page.locator(this.templateTable).first(); const addBtnVisible = await addBtn.isVisible({ timeout: 3000 }).catch(() => false); const contentVisible = await templatesContent.isVisible({ timeout: 3000 }).catch(() => false); @@ -95,13 +97,8 @@ export class AlertTemplatesPage { testLogger.warn('URL navigation to templates failed, trying menu path', { error: navError.message }); } - // Fallback: Navigate via Settings menu - await this.page.locator(this.settingsMenuItem).waitFor({ state: 'visible', timeout: 15000 }); - await this.page.locator(this.settingsMenuItem).click(); - await this.page.waitForTimeout(2000); - - await this.page.locator(this.templatesTab).waitFor({ state: 'visible', timeout: 15000 }); - await this.page.locator(this.templatesTab).click(); + // Fallback: navigate via the Reliability nav group (it left Settings). + await openNavFlyoutChild(this.page, 'templates'); await this.page.waitForTimeout(2000); // Wait for templates page to load @@ -745,7 +742,7 @@ export class AlertTemplatesPage { // The URL with action=import triggers TemplateList's onMounted → getTemplates → updateRoute → showImportTemplate const baseUrl = process.env.ZO_BASE_URL || 'http://localhost:5080'; const orgIdentifier = process.env.ORGNAME || 'default'; - const importUrl = `${baseUrl}/web/settings/templates?org_identifier=${orgIdentifier}&action=import`; + const importUrl = `${baseUrl}/web/alert-templates?org_identifier=${orgIdentifier}&action=import`; try { await this.page.goto(importUrl, { waitUntil: 'domcontentloaded', timeout: 20000 }); @@ -1219,7 +1216,7 @@ export class AlertTemplatesPage { async navigateToTemplatesPage() { const baseUrl = process.env.ZO_BASE_URL || 'http://localhost:5080'; const orgIdentifier = process.env.ORGNAME || 'default'; - const templatesUrl = `${baseUrl}/web/settings/templates?org_identifier=${orgIdentifier}`; + const templatesUrl = `${baseUrl}/web/alert-templates?org_identifier=${orgIdentifier}`; await this.page.goto(templatesUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); await this.page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {}); testLogger.info('Navigated directly to templates page via URL'); diff --git a/tests/ui-testing/pages/alertsPages/alertsFormValidationPage.js b/tests/ui-testing/pages/alertsPages/alertsFormValidationPage.js index 5742914b1b..65e8502e2a 100644 --- a/tests/ui-testing/pages/alertsPages/alertsFormValidationPage.js +++ b/tests/ui-testing/pages/alertsPages/alertsFormValidationPage.js @@ -1,6 +1,7 @@ // Copyright 2026 OpenObserve Inc. const testLogger = require('../../playwright-tests/utils/test-logger.js'); +const { openNavFlyoutChild } = require('../commonActions.js'); export class AlertsFormValidationPage { /** @@ -10,9 +11,8 @@ export class AlertsFormValidationPage { this.page = page; // ── Navigation ────────────────────────────────────────────────────────── - this.settingsMenuItem = '[data-test="menu-link-\\/settings-item"]'; - this.destinationsTab = '[data-test="alert-destinations-tab"]'; - this.templatesTab = '[data-test="alert-templates-tab"]'; + // Destinations and Templates left Settings for the Reliability nav group, + // so they are reached through its flyout rather than a settings tab. // ── Destinations list ──────────────────────────────────────────────────── this.addDestinationBtn = '[data-test="alert-destination-list-add-alert-btn"]'; @@ -166,18 +166,14 @@ export class AlertsFormValidationPage { } async navigateToDestinations() { - testLogger.info('Navigating to Settings > Destinations'); - await this.page.locator(this.settingsMenuItem).click(); - await this.page.locator(this.destinationsTab).waitFor({ state: 'visible', timeout: 15000 }); - await this.page.locator(this.destinationsTab).click(); + testLogger.info('Navigating to Reliability > Notification Destinations'); + await openNavFlyoutChild(this.page, 'destinations'); await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); } async navigateToTemplates() { - testLogger.info('Navigating to Settings > Templates'); - await this.page.locator(this.settingsMenuItem).click(); - await this.page.locator(this.templatesTab).waitFor({ state: 'visible', timeout: 15000 }); - await this.page.locator(this.templatesTab).click(); + testLogger.info('Navigating to Reliability > Templates'); + await openNavFlyoutChild(this.page, 'templates'); await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); } diff --git a/tests/ui-testing/pages/commonActions.js b/tests/ui-testing/pages/commonActions.js index a250f0f5ed..d5f38c390a 100644 --- a/tests/ui-testing/pages/commonActions.js +++ b/tests/ui-testing/pages/commonActions.js @@ -18,6 +18,9 @@ import testLogger from '../playwright-tests/utils/test-logger.js'; export const NAV_GROUP_TILE = { data: '[data-test="menu-link-\\/streams-item"]', dashboards: '[data-test="menu-link-\\/dashboards-item"]', + // The group root, not its inner tile: hover is handled on the root, and this + // selector does not move if the group's parentLink ever changes. + reliability: '[data-test="nav-group-reliability"]', }; // route `name` of each child within its group (matches navGroups.ts). @@ -28,6 +31,11 @@ export const NAV_FLYOUT_CHILD = { ingestion: { group: 'data', name: 'ingestion' }, streams: { group: 'data', name: 'logstreams' }, reports: { group: 'dashboards', name: 'reports' }, + // Notification Destinations and Templates left Settings for the Reliability + // group — they are alerting configuration. Their pages are unchanged; only + // the way you reach them is. + destinations: { group: 'reliability', name: 'alertDestinations' }, + templates: { group: 'reliability', name: 'alertTemplates' }, }; /** diff --git a/tests/ui-testing/pages/generalPages/homePage.js b/tests/ui-testing/pages/generalPages/homePage.js index e994407c4a..e21dc2ef3b 100644 --- a/tests/ui-testing/pages/generalPages/homePage.js +++ b/tests/ui-testing/pages/generalPages/homePage.js @@ -784,16 +784,15 @@ export class HomePage { } /** - * Navigate to Alert Destinations tab in Settings + * Navigate to Notification Destinations via the Reliability nav group. + * No longer a Settings tab — it moved to /alert-destinations. */ async navigateToAlertDestinations() { - await this.navigateToSettings(); - await this.page.locator('[data-test="alert-destinations-tab"]').waitFor({ state: 'visible', timeout: 10000 }); - await this.page.locator('[data-test="alert-destinations-tab"]').click(); + await openNavFlyoutChild(this.page, 'destinations'); } /** - * Validate Settings - Alert Destinations page UI elements + * Validate Notification Destinations page UI elements */ async validateSettingsAlertDestinationsPageElements() { await expect(this.page.locator('[data-test="alert-destination-list-add-alert-btn"]')).toBeVisible({ timeout: 10000 }); @@ -821,16 +820,15 @@ export class HomePage { } /** - * Navigate to Templates tab in Settings + * Navigate to Templates via the Reliability nav group. + * No longer a Settings tab — it moved to /alert-templates. */ async navigateToTemplates() { - await this.navigateToSettings(); - await this.page.locator('[data-test="alert-templates-tab"]').waitFor({ state: 'visible', timeout: 10000 }); - await this.page.locator('[data-test="alert-templates-tab"]').click(); + await openNavFlyoutChild(this.page, 'templates'); } /** - * Validate Settings - Templates page UI elements + * Validate Templates page UI elements */ async validateSettingsTemplatesPageElements() { await expect(this.page.locator('[data-test="template-list-add-btn"]')).toBeVisible({ timeout: 10000 }); diff --git a/tests/ui-testing/playwright-tests/Alerts/alerts-destinations-prebuilt.spec.js b/tests/ui-testing/playwright-tests/Alerts/alerts-destinations-prebuilt.spec.js index 94f449f4ae..64bc7f023b 100644 --- a/tests/ui-testing/playwright-tests/Alerts/alerts-destinations-prebuilt.spec.js +++ b/tests/ui-testing/playwright-tests/Alerts/alerts-destinations-prebuilt.spec.js @@ -43,7 +43,7 @@ test.describe("Prebuilt Alert Destinations E2E", () => { } // Navigate directly to alert destinations page - await page.goto(`${process.env["ZO_BASE_URL"]}/web/settings/alert_destinations?org_identifier=${getOrgIdentifier()}`); + await page.goto(`${process.env["ZO_BASE_URL"]}/web/alert-destinations?org_identifier=${getOrgIdentifier()}`); await page.waitForLoadState('networkidle', { timeout: NETWORK_IDLE_TIMEOUT_MS }).catch(() => {}); // Anchor on the list title rendering — deterministic signal that the destinations page is ready. await pm.alertDestinationsPage.expectDestinationsListTitleVisible(); diff --git a/tests/ui-testing/playwright-tests/Alerts/alerts-metrics-notification.spec.js b/tests/ui-testing/playwright-tests/Alerts/alerts-metrics-notification.spec.js index 10120b6814..86eaf376e3 100644 --- a/tests/ui-testing/playwright-tests/Alerts/alerts-metrics-notification.spec.js +++ b/tests/ui-testing/playwright-tests/Alerts/alerts-metrics-notification.spec.js @@ -359,7 +359,7 @@ test.describe("Metrics Alert Notification Chain", () => { const baseUrl = process.env.ZO_BASE_URL || 'http://localhost:5080'; const org = getOrgIdentifier(); - await page.goto(`${baseUrl}/web/settings/templates?org_identifier=${org}`); + await page.goto(`${baseUrl}/web/alert-templates?org_identifier=${org}`); await page.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {}); await pm.alertsPage.searchTemplate(TEMPLATE_NAME); @@ -387,7 +387,7 @@ test.describe("Metrics Alert Notification Chain", () => { testLogger.info('=== PHASE 3: Navigate back to templates list ==='); - await page.goto(`${baseUrl}/web/settings/templates?org_identifier=${org}`); + await page.goto(`${baseUrl}/web/alert-templates?org_identifier=${org}`); await page.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {}); testLogger.info('=== TEMPLATE VERIFICATION COMPLETE ==='); diff --git a/tests/ui-testing/playwright-tests/GeneralTests/landingPage.spec.js b/tests/ui-testing/playwright-tests/GeneralTests/landingPage.spec.js index f10661168e..4c4c0c3198 100644 --- a/tests/ui-testing/playwright-tests/GeneralTests/landingPage.spec.js +++ b/tests/ui-testing/playwright-tests/GeneralTests/landingPage.spec.js @@ -323,14 +323,14 @@ test.describe("Landing Page Test Cases", () => { } }, { - name: 'Settings - Alert Destinations', + name: 'Reliability - Notification Destinations', navigate: async () => { await pm.homePage.navigateToAlertDestinations(); }, - urlPattern: /alert_destinations/, + urlPattern: /alert-destinations/, uiChecks: async () => { await pm.homePage.validateSettingsAlertDestinationsPageElements(); - testLogger.info('Settings - Alert Destinations: Validated add destination button'); + testLogger.info('Reliability - Notification Destinations: Validated add destination button'); } }, { @@ -350,14 +350,14 @@ test.describe("Landing Page Test Cases", () => { skipIfNotVisible: true }, { - name: 'Settings - Templates', + name: 'Reliability - Templates', navigate: async () => { await pm.homePage.navigateToTemplates(); }, - urlPattern: /templates/, + urlPattern: /alert-templates/, uiChecks: async () => { await pm.homePage.validateSettingsTemplatesPageElements(); - testLogger.info('Settings - Templates: Validated add template button'); + testLogger.info('Reliability - Templates: Validated add template button'); } }, { diff --git a/web/src/components/settings/index.spec.ts b/web/src/components/settings/index.spec.ts index 1bc4750025..d9e563dbf8 100644 --- a/web/src/components/settings/index.spec.ts +++ b/web/src/components/settings/index.spec.ts @@ -178,16 +178,21 @@ describe("SettingsIndex", () => { expect(items.some((i: any) => i.key === "organization")).toBe(true); }); - it("should include alert-destinations item", () => { + // Notification Destinations and Templates moved to Reliability + // (/alerts/destinations, /alerts/templates) — they are alerting + // configuration, not deployment configuration. Settings keeps only + // Pipeline Destinations. + it("should not include alert-destinations or alert-templates items", () => { const wrapper = createWrapper(); const items = getAllItems(wrapper); - expect(items.some((i: any) => i.dataTest === "alert-destinations-tab")).toBe(true); + expect(items.some((i: any) => i.dataTest === "alert-destinations-tab")).toBe(false); + expect(items.some((i: any) => i.dataTest === "alert-templates-tab")).toBe(false); }); - it("should include alert-templates item", () => { + it("should still include pipeline-destinations item", () => { const wrapper = createWrapper(); const items = getAllItems(wrapper); - expect(items.some((i: any) => i.dataTest === "alert-templates-tab")).toBe(true); + expect(items.some((i: any) => i.dataTest === "pipeline-destinations-tab")).toBe(true); }); it("should include synthetics_locations item with correct properties", () => { @@ -303,11 +308,12 @@ describe("SettingsIndex", () => { expect(generalGroup).toBeDefined(); }); - it("should contain DESTINATIONS & TEMPLATES group", () => { + it("should contain a Destinations group holding only Pipeline Destinations", () => { const wrapper = createWrapper(); const groups = wrapper.vm.sectionGroups as any[]; - const destGroup = groups.find((g: any) => g.label === "Destinations & Templates"); + const destGroup = groups.find((g: any) => g.label === "Destinations"); expect(destGroup).toBeDefined(); + expect(destGroup.items.map((i: any) => i.key)).toEqual(["pipeline_destinations"]); }); it("should contain a Synthetics group", () => { diff --git a/web/src/components/settings/index.vue b/web/src/components/settings/index.vue index 6edaa76db8..573f4e34e7 100644 --- a/web/src/components/settings/index.vue +++ b/web/src/components/settings/index.vue @@ -89,9 +89,7 @@ export default defineComponent({ queryManagement: "queryManagement", query_management: "queryManagement", domainManagement: "domain_management", - alertDestinations: "alert_destinations", pipelineDestinations: "pipeline_destinations", - alertTemplates: "templates", modelPricing: "model_pricing", modelPricingEditor: "model_pricing", llmProviders: "llm_providers", @@ -174,7 +172,7 @@ export default defineComponent({ const settingsGroupOrder = [ "General", "Access & Security", - "Destinations & Templates", + "Destinations", "Data & AI", "Operations", "Synthetics", @@ -238,15 +236,10 @@ export default defineComponent({ dataTest: "domain-management-tab", group: "Access & Security", }, - { - key: "alert_destinations", - label: t("alert_destinations.header"), - description: t("settings.alertDestinationsDesc"), - icon: "location-on", - to: { name: "alertDestinations", query: { org_identifier: org } }, - dataTest: "alert-destinations-tab", - group: "Destinations & Templates", - }, + // Notification Destinations and Templates are alerting configuration and + // now live under Reliability (/alert-destinations, /alert-templates). + // Pipeline Destinations stays here — it belongs to pipelines, not + // alerting — so the group is just "Destinations" now. { key: "pipeline_destinations", label: t("pipeline_destinations.header"), @@ -255,16 +248,7 @@ export default defineComponent({ to: { name: "pipelineDestinations", query: { org_identifier: org } }, visible: isEnt, dataTest: "pipeline-destinations-tab", - group: "Destinations & Templates", - }, - { - key: "templates", - label: t("alert_templates.header"), - description: t("settings.templatesDesc"), - icon: "description", - to: { name: "alertTemplates", query: { org_identifier: org } }, - dataTest: "alert-templates-tab", - group: "Destinations & Templates", + group: "Destinations", }, { key: "storageSettings", @@ -396,7 +380,7 @@ export default defineComponent({ const groupLabels: Record = { General: t("settings.groupGeneral"), "Access & Security": t("settings.groupAccessSecurity"), - "Destinations & Templates": t("settings.groupDestinationsTemplates"), + Destinations: t("settings.groupDestinations"), "Data & AI": t("settings.groupDataAI"), Operations: t("settings.groupOperations"), Synthetics: t("settings.groupSynthetics"), diff --git a/web/src/components/settings/settingsIndex.spec.ts b/web/src/components/settings/settingsIndex.spec.ts index 8200934d5b..f4f0627328 100644 --- a/web/src/components/settings/settingsIndex.spec.ts +++ b/web/src/components/settings/settingsIndex.spec.ts @@ -368,15 +368,18 @@ describe("SettingsIndex.vue", () => { expect(keys).toContain("organization"); }); - it("should include DESTINATIONS & TEMPLATES group with alert destinations", () => { + it("should include a Destinations group without the alerting entries", () => { + // Notification Destinations and Templates moved to Reliability; only + // Pipeline Destinations is still deployment configuration. mockRouter.currentRoute.value.name = "general"; wrapper = createWrapper(); const groups = wrapper.vm.sectionGroups; - const destGroup = groups.find((g: any) => g.label === "Destinations & Templates"); + const destGroup = groups.find((g: any) => g.label === "Destinations"); expect(destGroup).toBeDefined(); const keys = destGroup.items.map((i: any) => i.key); - expect(keys).toContain("alert_destinations"); - expect(keys).toContain("templates"); + expect(keys).toContain("pipeline_destinations"); + expect(keys).not.toContain("alert_destinations"); + expect(keys).not.toContain("templates"); }); }); diff --git a/web/src/composables/shared/router.ts b/web/src/composables/shared/router.ts index 7ff6a054e0..50428f3ca4 100644 --- a/web/src/composables/shared/router.ts +++ b/web/src/composables/shared/router.ts @@ -44,6 +44,8 @@ const StreamExplorer = () => import("@/views/StreamExplorer.vue"); const LogStream = () => import("@/views/LogStream.vue"); const Dashboards = () => import("@/views/Dashboards/Dashboards.vue"); const AlertList = () => import("@/components/alerts/AlertList.vue"); +const AlertsDestinationList = () => import("@/components/alerts/AlertsDestinationList.vue"); +const TemplateList = () => import("@/components/alerts/TemplateList.vue"); const Functions = () => import("@/views/Functions.vue"); const FunctionList = () => import("@/components/functions/FunctionList.vue"); @@ -479,6 +481,40 @@ const useRoutes = () => { routeGuard(to, from, next); }, }, + { + // Notification destinations and templates: alerting configuration, so + // they moved out of /settings (which wrapped them in the Settings shell) + // and into the Reliability rail group. + // + // Top-level and FLAT, not under /alerts: they are siblings of Alerts, not + // sub-pages of it. Nesting them made the URL claim otherwise, and the rail + // believed it — /alerts/destinations lit up Alerts as well, because that + // is exactly how a real drill-down like /alerts/detail/:id behaves. Every + // other Reliability section is top-level too (/alerts, /slos, /incidents). + // + // The route NAMES are unchanged — every call site navigates by name — and + // the old /settings/* paths still redirect here for existing bookmarks. + path: "alert-destinations", + name: "alertDestinations", + component: AlertsDestinationList, + meta: { + title: "Notification Destinations", + }, + beforeEnter(to: any, from: any, next: any) { + routeGuard(to, from, next); + }, + }, + { + path: "alert-templates", + name: "alertTemplates", + component: TemplateList, + meta: { + title: "Templates", + }, + beforeEnter(to: any, from: any, next: any) { + routeGuard(to, from, next); + }, + }, { // Alert status page. Replaces the row-click side panel, and is where a // multi-alert's per-group state lives (alerts_2.md §5.4). diff --git a/web/src/composables/shared/useManagementRoutes.spec.ts b/web/src/composables/shared/useManagementRoutes.spec.ts index 23ca671bbc..10d73437d6 100644 --- a/web/src/composables/shared/useManagementRoutes.spec.ts +++ b/web/src/composables/shared/useManagementRoutes.spec.ts @@ -134,20 +134,32 @@ describe("useManagementRoutes", () => { expect(orgRoute.path).toBe("organization"); }); - it("should have alertDestinations route", () => { + // Notification Destinations and Templates moved to /alert-* (Reliability). + // What is left here is a bare redirect, so old bookmarks still resolve. + it("should redirect alert_destinations to alertDestinations, preserving the query", () => { const alertDestRoute = routes[0].children.find( - (child: any) => child.name === "alertDestinations", + (child: any) => child.path === "alert_destinations", ); expect(alertDestRoute).toBeDefined(); - expect(alertDestRoute.path).toBe("alert_destinations"); + expect(alertDestRoute.name).toBeUndefined(); + // `action=import` opens the import view — dropping the query would break + // every existing deep link into it. + expect(alertDestRoute.redirect({ query: { org_identifier: "o", action: "import" } })).toEqual( + { + name: "alertDestinations", + query: { org_identifier: "o", action: "import" }, + }, + ); }); - it("should have alertTemplates route", () => { - const templateRoute = routes[0].children.find( - (child: any) => child.name === "alertTemplates", - ); + it("should redirect templates to alertTemplates, preserving the query", () => { + const templateRoute = routes[0].children.find((child: any) => child.path === "templates"); expect(templateRoute).toBeDefined(); - expect(templateRoute.path).toBe("templates"); + expect(templateRoute.name).toBeUndefined(); + expect(templateRoute.redirect({ query: { org_identifier: "o", action: "import" } })).toEqual({ + name: "alertTemplates", + query: { org_identifier: "o", action: "import" }, + }); }); it("should NOT have llmProviders route in OSS builds", () => { @@ -174,19 +186,8 @@ describe("useManagementRoutes", () => { expect(typeof orgRoute.beforeEnter).toBe("function"); }); - it("should have beforeEnter hook for alertDestinations route", () => { - const alertDestRoute = routes[0].children.find( - (child: any) => child.name === "alertDestinations", - ); - expect(typeof alertDestRoute.beforeEnter).toBe("function"); - }); - - it("should have beforeEnter hook for alertTemplates route", () => { - const templateRoute = routes[0].children.find( - (child: any) => child.name === "alertTemplates", - ); - expect(typeof templateRoute.beforeEnter).toBe("function"); - }); + // The two alerting redirects carry no guard by design: they resolve to + // /alerts/* routes, which run routeGuard themselves. it("should call routeGuard in general route beforeEnter", () => { const generalRoute = routes[0].children.find((child: any) => child.name === "general"); @@ -210,34 +211,12 @@ describe("useManagementRoutes", () => { expect(routeGuard).toHaveBeenCalledWith(mockTo, mockFrom, mockNext); }); - it("should call routeGuard in alertDestinations route beforeEnter", () => { - const alertDestRoute = routes[0].children.find( - (child: any) => child.name === "alertDestinations", - ); - const mockTo = { path: "/settings/alert_destinations" }; - const mockFrom = { path: "/settings" }; - const mockNext = vi.fn(); - - alertDestRoute.beforeEnter(mockTo, mockFrom, mockNext); - expect(routeGuard).toHaveBeenCalledWith(mockTo, mockFrom, mockNext); - }); - - it("should call routeGuard in alertTemplates route beforeEnter", () => { - const templateRoute = routes[0].children.find( - (child: any) => child.name === "alertTemplates", - ); - const mockTo = { path: "/settings/templates" }; - const mockFrom = { path: "/settings" }; - const mockNext = vi.fn(); - - templateRoute.beforeEnter(mockTo, mockFrom, mockNext); - expect(routeGuard).toHaveBeenCalledWith(mockTo, mockFrom, mockNext); - }); - - it("should have component defined for each base child route", () => { - routes[0].children.forEach((child: any) => { - expect(child.component).toBeDefined(); - }); + it("should have component defined for each non-redirect base child route", () => { + routes[0].children + .filter((child: any) => !child.redirect) + .forEach((child: any) => { + expect(child.component).toBeDefined(); + }); }); it("should have correct path for each base child route", () => { @@ -253,8 +232,13 @@ describe("useManagementRoutes", () => { expect(actualPaths).toEqual(expectedPaths); }); - it("should have unique names for each base child route", () => { - const names = routes[0].children.slice(0, 6).map((child: any) => child.name); + it("should have unique names for each named base child route", () => { + // Redirect-only children are deliberately unnamed — their name belongs to + // the /alerts/* route they point at. + const names = routes[0].children + .slice(0, 6) + .filter((child: any) => !child.redirect) + .map((child: any) => child.name); const uniqueNames = [...new Set(names)]; expect(names).toHaveLength(uniqueNames.length); }); @@ -575,11 +559,15 @@ describe("useManagementRoutes", () => { }); describe("Route Configuration Validation", () => { - it("should have unique route names across all children", () => { + it("should have unique route names across all named children", () => { config.isEnterprise = "true"; config.isCloud = "true"; const routes = useManagementRoutes(); - const names = routes[0].children.map((child: any) => child.name); + // Redirect-only children are unnamed by design (their name lives on the + // /alerts/* target), so they cannot participate in a uniqueness check. + const names = routes[0].children + .filter((child: any) => !child.redirect) + .map((child: any) => child.name); const uniqueNames = [...new Set(names)]; expect(names).toHaveLength(uniqueNames.length); }); @@ -597,11 +585,15 @@ describe("useManagementRoutes", () => { config.isEnterprise = "true"; config.isCloud = "true"; const routes = useManagementRoutes(); - routes[0].children.forEach((child: any) => { - expect(child.name).toBeDefined(); - expect(child.name).not.toBe(""); - expect(typeof child.name).toBe("string"); - }); + // Redirect-only children are unnamed on purpose; every other child must + // carry a real name. + routes[0].children + .filter((child: any) => !child.redirect) + .forEach((child: any) => { + expect(child.name).toBeDefined(); + expect(child.name).not.toBe(""); + expect(typeof child.name).toBe("string"); + }); }); it("should not have empty or undefined route paths", () => { @@ -615,16 +607,18 @@ describe("useManagementRoutes", () => { }); }); - it("should have valid component imports for all routes", () => { + it("should have valid component imports for all non-redirect routes", () => { config.isEnterprise = "true"; config.isCloud = "true"; const routes = useManagementRoutes(); - routes[0].children.forEach((child: any) => { - expect(child.component).toBeDefined(); - expect(typeof child.component === "function" || typeof child.component === "object").toBe( - true, - ); - }); + routes[0].children + .filter((child: any) => !child.redirect) + .forEach((child: any) => { + expect(child.component).toBeDefined(); + expect(typeof child.component === "function" || typeof child.component === "object").toBe( + true, + ); + }); }); }); diff --git a/web/src/composables/shared/useManagementRoutes.ts b/web/src/composables/shared/useManagementRoutes.ts index b19594e462..90a6cba6e6 100644 --- a/web/src/composables/shared/useManagementRoutes.ts +++ b/web/src/composables/shared/useManagementRoutes.ts @@ -2,8 +2,6 @@ import config from "@/aws-exports"; import { routeGuard } from "@/utils/zincutils"; const Settings = () => import("@/components/settings/index.vue"); -const TemplateList = () => import("@/components/alerts/TemplateList.vue"); -const AlertsDestinationList = () => import("@/components/alerts/AlertsDestinationList.vue"); const useManagementRoutes = () => { const routes: any = [ @@ -41,16 +39,14 @@ const useManagementRoutes = () => { routeGuard(to, from, next); }, }, + // Notification destinations moved to /alert-destinations (Reliability). + // Kept as a redirect so existing bookmarks and links still resolve. + // The function form is required to carry the query across: callers pass + // `org_identifier`, and `?action=import` opens the import view — an + // object redirect would silently drop both. { path: "alert_destinations", - name: "alertDestinations", - meta: { - title: "Alert Destinations", - }, - component: AlertsDestinationList, - beforeEnter(to: any, from: any, next: any) { - routeGuard(to, from, next); - }, + redirect: (to: any) => ({ name: "alertDestinations", query: to.query }), }, { path: "model_pricing", @@ -75,16 +71,11 @@ const useManagementRoutes = () => { routeGuard(to, from, next); }, }, + // Alert templates moved to /alert-templates (Reliability). Redirect kept + // for the same reason as alert_destinations above, query included. { path: "templates", - name: "alertTemplates", - meta: { - title: "Templates", - }, - component: TemplateList, - beforeEnter(to: any, from: any, next: any) { - routeGuard(to, from, next); - }, + redirect: (to: any) => ({ name: "alertTemplates", query: to.query }), }, ], }, diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue index f9216c04f9..f1dd9fff43 100644 --- a/web/src/layouts/MainLayout.vue +++ b/web/src/layouts/MainLayout.vue @@ -385,6 +385,14 @@ export default defineComponent({ ); }); + // Backend `/config` flag `slo_enabled` — controlled by `ZO_SLO_ENABLED`. + // NOT build-gated: SLO measurement is an OSS capability, so unlike + // Synthetics/Incidents this deliberately has no enterprise/cloud check. + // `=== true`, not truthy: /config is fetched without await, so the flag is + // briefly undefined and the entry must stay hidden rather than flash in + // and then navigate to a page the API answers with 501. + const isSloEnabled = computed(() => store.state.zoConfig?.slo_enabled === true); + // Real entries carry `identifier`; the placeholder literal only sets label/value. const orgOptions = ref>([ { label: Number, value: String }, @@ -446,16 +454,8 @@ export default defineComponent({ link: "/alerts", name: "alertList", }, - // TODO(slo): the SLO feature is deferred — menu entry hidden until it - // ships. Restore this entry (directly after Alerts: an SLO is what an - // SLO alert burns against) and the navGroups "alerts" group will absorb - // it again. - // { - // title: t("menu.slos"), - // icon: "target", - // link: "/slos", - // name: "sloList", - // }, + // SLOs are spliced in by updateSloMenu() when `slo_enabled` is on — + // directly after Alerts, since an SLO is what an SLO alert burns against. { title: t("menu.ingestion"), icon: "data-plus-line", @@ -623,6 +623,33 @@ export default defineComponent({ } }; + // Insert / remove the SLOs entry directly after Alerts. Like Workflows and + // Synthetics this REMOVES when the flag is off rather than merely skipping: + // the menu is rebuilt on org switch and `slo_enabled` can differ per + // deployment, so an add-only guard would leave a stale entry behind. + const updateSloMenu = () => { + const existingIndex = linksList.value.findIndex((l: any) => l.name === "sloList"); + + if (!isSloEnabled.value) { + if (existingIndex !== -1) linksList.value.splice(existingIndex, 1); + return; + } + if (existingIndex !== -1) return; + + const alertIndex = linksList.value.findIndex((l: any) => l.name === "alertList"); + if (alertIndex === -1) return; + + linksList.value.splice(alertIndex + 1, 0, { + title: t("menu.slos"), + icon: "target", + link: "/slos", + name: "sloList", + }); + }; + + // Keep the menu in sync if /config resolves after mount. + watch(isSloEnabled, () => updateSloMenu(), { immediate: false }); + const updateActionsMenu = () => { if (isActionsEnabled.value) { const incidentIndex = linksList.value.findIndex((link) => link.name === "incidentList"); @@ -732,6 +759,8 @@ export default defineComponent({ const filterMenus = () => { updateIncidentsMenu(); + // After Incidents, so the flat order reads Alerts → SLOs → Incidents. + updateSloMenu(); updateActionsMenu(); updateWorkflowsMenu(); updateSyntheticMenu(); diff --git a/web/src/lib/core/Navbar/ONavGroup.spec.ts b/web/src/lib/core/Navbar/ONavGroup.spec.ts index 481905b626..78ae9f17f1 100644 --- a/web/src/lib/core/Navbar/ONavGroup.spec.ts +++ b/web/src/lib/core/Navbar/ONavGroup.spec.ts @@ -191,4 +191,107 @@ describe("ONavGroup", () => { await flushPromises(); expect(flyout().exists()).toBe(false); }); + + // Exactly one row may be active. Destinations/Templates are SIBLINGS of + // Alerts, not sub-pages, so their paths are top-level and flat — that is what + // keeps them unambiguous. The deepest-match rule below is the second line of + // defence for genuinely nested sections. + describe("active state across sibling sections", () => { + const reliabilityChildren: SubnavChild[] = [ + { titleKey: "menu.alerts", icon: "shield-alert-outline", name: "alertList" }, + { titleKey: "alert_destinations.header", icon: "location-on", name: "alertDestinations" }, + { titleKey: "alert_templates.header", icon: "description", name: "alertTemplates" }, + ]; + + function makeReliabilityRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: "/", name: "home", component: { template: "
" } }, + { path: "/alerts", name: "alertList", component: { template: "
" } }, + { + path: "/alert-destinations", + name: "alertDestinations", + component: { template: "
" }, + }, + { + path: "/alert-templates", + name: "alertTemplates", + component: { template: "
" }, + }, + // A real drill-down under Alerts — this one SHOULD mark Alerts. + { + path: "/alerts/detail/:id", + name: "alertDetail", + component: { template: "
" }, + }, + ], + }); + } + + async function mountAt(path: string, children = reliabilityChildren) { + const router = makeReliabilityRouter(); + router.push(path); + await router.isReady(); + const w = mount(ONavGroup, { + props: { + groupKey: "reliability", + title: "Reliability", + icon: "shield", + children, + parentItem: { + link: "/alerts", + title: "Reliability", + icon: "shield", + name: "alertList", + }, + }, + global: { + plugins: [router, store, i18n], + stubs: { MenuLink: menuLinkStub, OIcon: oIconStub, teleport: true }, + }, + }); + await w.trigger("mouseenter"); + vi.advanceTimersByTime(OPEN_DELAY); + await flushPromises(); + return w; + } + + function activeNames(w: VueWrapper): string[] { + return w + .findAll('[data-test^="nav-group-item-"]') + .filter((el) => el.attributes("aria-current") === "page") + .map((el) => el.attributes("data-test")!.replace("nav-group-item-", "")); + } + + it("marks only Notification Destinations on /alert-destinations", async () => { + wrapper = await mountAt("/alert-destinations"); + expect(activeNames(wrapper)).toEqual(["alertDestinations"]); + }); + + it("marks only Templates on /alert-templates", async () => { + wrapper = await mountAt("/alert-templates"); + expect(activeNames(wrapper)).toEqual(["alertTemplates"]); + }); + + it("marks only Alerts on /alerts", async () => { + wrapper = await mountAt("/alerts"); + expect(activeNames(wrapper)).toEqual(["alertList"]); + }); + + it("attributes an alert drill-down to Alerts", async () => { + wrapper = await mountAt("/alerts/detail/abc"); + expect(activeNames(wrapper)).toEqual(["alertList"]); + }); + + // Guard for any future section that IS nested under a sibling: the deepest + // matching path wins, so the ancestor no longer lights up alongside it. + it("gives a nested section to itself, not to its ancestor", async () => { + wrapper = await mountAt("/alerts/detail/abc", [ + { titleKey: "menu.alerts", icon: "shield-alert-outline", name: "alertList" }, + { titleKey: "menu.alerts", icon: "shield-alert-outline", name: "alertDetail" }, + ]); + expect(activeNames(wrapper)).toEqual(["alertDetail"]); + }); + }); }); diff --git a/web/src/lib/core/Navbar/ONavGroup.vue b/web/src/lib/core/Navbar/ONavGroup.vue index fbeaa6fe67..e0d0a3ea86 100644 --- a/web/src/lib/core/Navbar/ONavGroup.vue +++ b/web/src/lib/core/Navbar/ONavGroup.vue @@ -171,21 +171,40 @@ function childPath(name: string): string | null { } } -function isChildActive(child: SubnavChild): boolean { +// At most ONE child is active, resolved for the whole flyout rather than per +// child. Deciding per child lit up ancestors too: any section whose path is a +// prefix of another's matched both, which is right for a drill-down like +// /alerts/detail/:id but wrong for a sibling that merely sits underneath. +// +// Exact route-name match wins outright; otherwise the DEEPEST path prefix wins, +// so a nested route is attributed to its own section, not a shallower sibling. +const activeChild = computed(() => { const route = router.currentRoute.value; - // Exact route-name match — precise for query-tab routes (AI evals). - if (route.name === child.name) { - return !child.tab || route.query.tab === child.tab; + + const exact = props.children.find( + (c) => route.name === c.name && (!c.tab || route.query.tab === c.tab), + ); + if (exact) return exact; + + let best: SubnavChild | null = null; + let bestLen = 0; + for (const child of props.children) { + if (child.tab) continue; // query-tab children only match by exact name + const base = childPath(child.name); + if (!base || base === "/") continue; + if (route.path !== base && !route.path.startsWith(`${base}/`)) continue; + if (base.length > bestLen) { + best = child; + bestLen = base.length; + } } - // Otherwise the section is still "active" when the current route is nested - // under it — drill-down editors, the ingestion ("Data sources") tab routes - // (e.g. ingestLogs under /ingestion), pipeline editors, etc. - if (child.tab) return false; // query-tab children only match by exact name - const base = childPath(child.name); - if (!base || base === "/") return false; - return route.path === base || route.path.startsWith(`${base}/`); + return best; +}); + +function isChildActive(child: SubnavChild): boolean { + return activeChild.value === child; } -const isGroupActive = computed(() => props.children.some(isChildActive)); +const isGroupActive = computed(() => activeChild.value !== null); const orgIdentifier = computed(() => store.state.selectedOrganization?.identifier); diff --git a/web/src/lib/core/Navbar/ONavbar.spec.ts b/web/src/lib/core/Navbar/ONavbar.spec.ts index bc27990acd..433610061a 100644 --- a/web/src/lib/core/Navbar/ONavbar.spec.ts +++ b/web/src/lib/core/Navbar/ONavbar.spec.ts @@ -135,7 +135,7 @@ describe("ONavbar", () => { ); }); - it("keeps Alerts and Reports as separate top-level links", () => { + it("collapses Alerts into Reliability and leaves Reports a separate link", () => { wrapper = mountNavbar({ linksList: [ { title: "Home", icon: "home", link: "/home", name: "home" }, @@ -144,9 +144,15 @@ describe("ONavbar", () => { ], }); - expect(wrapper.find('[data-test="menu-link-alertList-item"]').exists()).toBe(true); + // Alerts brings Destinations/Templates with it, so it is a group tile + // rather than a bare link; Dashboards is absent so Reports stays a link. + expect(wrapper.find('[data-test="menu-link-alertList-item"]').exists()).toBe(false); + const reliability = wrapper.find('[data-test="nav-group-reliability"]'); + expect(reliability.exists()).toBe(true); + expect(reliability.attributes("data-children")).toBe( + "alertList,alertDestinations,alertTemplates", + ); expect(wrapper.find('[data-test="menu-link-reports-item"]').exists()).toBe(true); - expect(wrapper.find('[data-test="nav-group-monitoring"]').exists()).toBe(false); }); it("renders IAM / Management / AI as plain links (no submenu)", () => { diff --git a/web/src/lib/core/Navbar/navGroups.spec.ts b/web/src/lib/core/Navbar/navGroups.spec.ts index a66625aef2..ebdde66e79 100644 --- a/web/src/lib/core/Navbar/navGroups.spec.ts +++ b/web/src/lib/core/Navbar/navGroups.spec.ts @@ -37,11 +37,22 @@ describe("groupNavLinks", () => { link("iam"), link("settings"), ]; - // Output mirrors the input order exactly (no reordering). - expect(keysOf(groupNavLinks(input))).toEqual(input.map((i) => `link:${i.name}`)); + // Output mirrors the input order exactly (no reordering). Only alertList + // changes shape — it collapses into the Reliability tile in its own slot. + expect(keysOf(groupNavLinks(input))).toEqual([ + "link:home", + "link:logs", + "link:metrics", + "link:traces", + "link:rum", + "link:dashboards", + "linkGroup:reliability", + "link:iam", + "link:settings", + ]); }); - it("places the Data group right after Incidents when present", () => { + it("places the Data group right after the Reliability group it is anchored to", () => { const entries = groupNavLinks([ link("home"), link("streams"), @@ -49,13 +60,29 @@ describe("groupNavLinks", () => { link("incidentList"), link("pipeline"), ]); - // streams/pipeline are absorbed; Data is emitted after incidentList. - expect(keysOf(entries)).toEqual([ - "link:home", - "link:alertList", - "link:incidentList", - "linkGroup:data", + // alertList/incidentList collapse into Reliability; streams/pipeline into + // Data, which follows the Reliability TILE rather than landing at the + // streams slot it would default to. + expect(keysOf(entries)).toEqual(["link:home", "linkGroup:reliability", "linkGroup:data"]); + }); + + it("falls back to default placement when the anchor group is inactive", () => { + // No alertList → Reliability never forms, so Data cannot follow it and + // lands at its own first absorbed item instead. + const entries = groupNavLinks([link("home"), link("streams"), link("pipeline"), link("iam")]); + expect(keysOf(entries)).toEqual(["link:home", "linkGroup:data", "link:iam"]); + }); + + it("puts Data after Reliability on OSS too (no Incidents)", () => { + const entries = groupNavLinks([ + link("home"), + link("streams"), + link("pipeline"), + link("alertList"), + link("sloList"), ]); + // Same order as enterprise: the anchor no longer depends on Incidents. + expect(keysOf(entries)).toEqual(["link:home", "linkGroup:reliability", "linkGroup:data"]); }); it("emits the Data group in place of its first absorbed item (no Incidents)", () => { @@ -97,53 +124,95 @@ describe("groupNavLinks", () => { ]); }); - it("keeps Alerts separate; Reports stays a link when Dashboards is absent", () => { + it("collapses Alerts into Reliability; Reports stays a link when Dashboards is absent", () => { const entries = groupNavLinks([link("home"), link("alertList"), link("reports")]); - expect(keysOf(entries)).toContain("link:alertList"); + // Destinations/Templates ride on alertList, so Alerts alone is already a + // three-child group — it never renders as a bare link. + expect(keysOf(entries)).toContain("linkGroup:reliability"); // Dashboards absent → the Dashboards group has only Reports (1 child) so it // doesn't collapse; Reports stays a plain link. expect(keysOf(entries)).toContain("link:reports"); - expect(entries.some((e) => e.type === "linkGroup")).toBe(false); }); - it("moves SLOs under the Alerts group", () => { - const entries = groupNavLinks([link("home"), link("alertList"), link("sloList")]); - // SLOs is absorbed; the Alerts tile takes the alertList slot. - expect(keysOf(entries)).toEqual(["link:home", "linkGroup:alerts"]); - const alerts = entries.find( + it("groups Alerts, SLOs, Incidents, Destinations and Templates under Reliability", () => { + // MainLayout splices Incidents between Alerts and SLOs; the Reliability tile + // takes the first absorbed slot (alertList) and the children keep the order + // declared in NAV_GROUPS, not the rail order. Destinations and Templates + // have no rail entry of their own — they ride on alertList. + const entries = groupNavLinks([ + link("home"), + link("alertList"), + link("incidentList"), + link("sloList"), + ]); + expect(keysOf(entries)).toEqual(["link:home", "linkGroup:reliability"]); + const reliability = entries.find( (e): e is Extract => - e.type === "linkGroup" && e.item.name === "alerts", + e.type === "linkGroup" && e.item.name === "reliability", ); - expect(alerts?.item.link).toBe("/alerts"); - expect(alerts?.children.map((c) => c.name)).toEqual(["alertList", "sloList"]); - }); - - it("keeps Alerts a plain link when SLOs is hidden", () => { - // custom_hide_menus can drop either item; a one-child group is pointless. - expect(keysOf(groupNavLinks([link("home"), link("alertList")]))).toEqual([ - "link:home", - "link:alertList", + // Clicking the tile lands on Alerts (always-present route). + expect(reliability?.item.link).toBe("/alerts"); + expect(reliability?.children.map((c) => c.name)).toEqual([ + "alertList", + "sloList", + "incidentList", + "alertDestinations", + "alertTemplates", ]); }); - it("keeps SLOs a plain link when Alerts is hidden", () => { + it("drops Incidents from Reliability on OSS (no incidents route)", () => { + const entries = groupNavLinks([link("home"), link("alertList"), link("sloList")]); + expect(keysOf(entries)).toEqual(["link:home", "linkGroup:reliability"]); + const reliability = entries.find( + (e): e is Extract => + e.type === "linkGroup" && e.item.name === "reliability", + ); + expect(reliability?.children.map((c) => c.name)).toEqual([ + "alertList", + "sloList", + "alertDestinations", + "alertTemplates", + ]); + }); + + it("still groups Alerts with its Destinations/Templates when SLOs and Incidents are hidden", () => { + const entries = groupNavLinks([link("home"), link("alertList")]); + expect(keysOf(entries)).toEqual(["link:home", "linkGroup:reliability"]); + const reliability = entries.find( + (e): e is Extract => + e.type === "linkGroup" && e.item.name === "reliability", + ); + expect(reliability?.children.map((c) => c.name)).toEqual([ + "alertList", + "alertDestinations", + "alertTemplates", + ]); + }); + + it("takes Destinations/Templates away with Alerts when alertList is hidden", () => { + // They carry `requires: "alertList"`, so hiding Alerts via custom_hide_menus + // must not leave its plumbing behind in the flyout. + const entries = groupNavLinks([link("home"), link("sloList"), link("incidentList")]); + const reliability = entries.find( + (e): e is Extract => + e.type === "linkGroup" && e.item.name === "reliability", + ); + expect(reliability?.children.map((c) => c.name)).toEqual(["sloList", "incidentList"]); + }); + + it("keeps SLOs a plain link when Alerts and Incidents are hidden", () => { expect(keysOf(groupNavLinks([link("home"), link("sloList")]))).toEqual([ "link:home", "link:sloList", ]); }); - it("keeps Incidents out of the Alerts group, between it and Data", () => { - // MainLayout splices Incidents after alertList, i.e. BETWEEN the two - // absorbed items — the Alerts tile must still land in the alertList slot. - const entries = groupNavLinks([ - link("alertList"), - link("incidentList"), - link("sloList"), - link("streams"), - link("pipeline"), + it("keeps Incidents a plain link when it is the only reliability item", () => { + expect(keysOf(groupNavLinks([link("home"), link("incidentList")]))).toEqual([ + "link:home", + "link:incidentList", ]); - expect(keysOf(entries)).toEqual(["linkGroup:alerts", "link:incidentList", "linkGroup:data"]); }); it("moves Reports under the Dashboards group", () => { @@ -154,7 +223,7 @@ describe("groupNavLinks", () => { link("alertList"), ]); // Reports is absorbed; the Dashboards tile takes the dashboards slot. - expect(keysOf(entries)).toEqual(["link:home", "linkGroup:dashboards", "link:alertList"]); + expect(keysOf(entries)).toEqual(["link:home", "linkGroup:dashboards", "linkGroup:reliability"]); const dash = entries.find( (e): e is Extract => e.type === "linkGroup" && e.item.name === "dashboards", @@ -176,7 +245,7 @@ describe("groupNavLinks", () => { link("alertList"), ]); // rum/synthetics are absorbed; the Experience tile takes rum's slot. - expect(keysOf(entries)).toEqual(["link:home", "linkGroup:experience", "link:alertList"]); + expect(keysOf(entries)).toEqual(["link:home", "linkGroup:experience", "linkGroup:reliability"]); const experience = entries.find( (e): e is Extract => e.type === "linkGroup" && e.item.name === "experience", diff --git a/web/src/lib/core/Navbar/navGroups.ts b/web/src/lib/core/Navbar/navGroups.ts index cbf5dc817e..4f557481d8 100644 --- a/web/src/lib/core/Navbar/navGroups.ts +++ b/web/src/lib/core/Navbar/navGroups.ts @@ -50,11 +50,11 @@ export const GATE_PREDICATES: Record boolean> = { * their own sub-pages on hover. * * Three shapes (see `RailEntry`): - * • plain link — most items (Home, Logs, Metrics, Traces, RUM, Alerts, - * Incidents, Actions, Billings, AI, IAM, Management). + * • plain link — most items (Home, Logs, Metrics, Traces, Actions, + * Billings, AI, IAM, Management). * • link + subnav — a tile that navigates to a main page on click AND surfaces - * a section nav on hover. Produced by NAV_GROUPS (Data → /streams, - * Dashboards → /dashboards) and by any NAV_SUBNAV entry. + * a section nav on hover. Produced by NAV_GROUPS (Reliability → /alerts, + * Data → /streams, Dashboards → /dashboards) and by any NAV_SUBNAV entry. * • pure group — a flyout with no page of its own (click toggles it). * Supported by the renderer but not emitted by any current entry. * @@ -82,7 +82,8 @@ export interface NavGroupDef { /** Top-level `name`s this group replaces (removed from the rail). */ absorbs: string[]; /** - * Emit the group's tile immediately AFTER this top-level item (when present). + * Emit the group's tile immediately AFTER this anchor — either a top-level + * item `name` or another group's `key` (when that anchor is present/active). * Defaults to the position of the group's first absorbed item. */ placeAfter?: string; @@ -91,11 +92,11 @@ export interface NavGroupDef { export const NAV_GROUPS: NavGroupDef[] = [ { - key: "alerts", - titleKey: "menu.alerts", - icon: "shield-alert-outline", + key: "reliability", + titleKey: "menu.reliability", + icon: "shield", parentLink: "/alerts", - absorbs: ["alertList", "sloList"], + absorbs: ["alertList", "sloList", "incidentList"], children: [ { titleKey: "menu.alerts", @@ -103,11 +104,36 @@ export const NAV_GROUPS: NavGroupDef[] = [ name: "alertList", requires: "alertList", }, - // An SLO is what an SLO alert burns against, so the two are navigated - // together. Both children carry `requires` so that hiding either one via - // `custom_hide_menus` collapses the group back to a plain link for the - // survivor rather than leaving a one-item flyout. + // An SLO is what an SLO alert burns against, and an incident is what an + // alert escalates into — one reliability workflow, one tile. Every child + // carries `requires` so that hiding any of them (`custom_hide_menus`, or + // Incidents being enterprise-gated) shrinks the group, and dropping to a + // single survivor collapses it back to a plain link rather than leaving a + // one-item flyout. { titleKey: "menu.slos", icon: "target", name: "sloList", requires: "sloList" }, + { + titleKey: "menu.incidents", + icon: "notifications-active", + name: "incidentList", + requires: "incidentList", + }, + // Where an alert is delivered, and the message it delivers. These moved + // out of Settings: they are alerting configuration, not deployment + // configuration. They have no rail entry of their own, so they ride on + // Alerts being present — hiding `alertList` via custom_hide_menus takes + // its plumbing with it. + { + titleKey: "alert_destinations.header", + icon: "location-on", + name: "alertDestinations", + requires: "alertList", + }, + { + titleKey: "alert_templates.header", + icon: "description", + name: "alertTemplates", + requires: "alertList", + }, ], }, { @@ -116,7 +142,10 @@ export const NAV_GROUPS: NavGroupDef[] = [ icon: "database", parentLink: "/streams", absorbs: ["streams", "pipeline", "ingestion"], - placeAfter: "incidentList", + // Data follows the Reliability tile. This is load-bearing: without it Data + // lands at its own first absorbed item (pipeline/streams), near the TOP of + // the rail, ahead of Experience and Dashboards. + placeAfter: "reliability", children: [ { titleKey: "menu.index", icon: "window", name: "logstreams", requires: "streams" }, // Pipeline expands into its own tabbed sub-pages (same visibility rules). @@ -213,13 +242,16 @@ export function groupNavLinks( return { type: "link", item }; }; - // A group is emitted either AFTER a named item (`placeAfter`, when that item is - // present) or in place of its first absorbed item (default). Map the anchor - // item name → group keys to emit right after it. + // A group is emitted either AFTER its `placeAfter` anchor or in place of its + // first absorbed item (default). Map anchor → group keys to emit right after + // it. The anchor is a top-level item `name` or another group's `key`; it only + // counts when that item is present / that group is active, so a group whose + // anchor never materialises falls back to default placement. + const anchorExists = (anchor: string) => presentNames.has(anchor) || groupChildren.has(anchor); const emitAfter = new Map(); for (const def of absorbedToGroup.values()) { if (!groupChildren.has(def.key)) continue; - if (def.placeAfter && presentNames.has(def.placeAfter)) { + if (def.placeAfter && anchorExists(def.placeAfter)) { const list = emitAfter.get(def.placeAfter) ?? []; if (!list.includes(def.key)) list.push(def.key); emitAfter.set(def.placeAfter, list); @@ -241,6 +273,13 @@ export function groupNavLinks( }, children: groupChildren.get(def.key)!, }); + // Groups anchored after THIS group (e.g. Data follows Reliability). + emitAnchored(def.key); + }; + const emitAnchored = (anchor: string) => { + for (const key of emitAfter.get(anchor) ?? []) { + emitGroup(NAV_GROUPS.find((d) => d.key === key)!); + } }; for (const item of links) { @@ -248,16 +287,12 @@ export function groupNavLinks( if (group) { // Absorbed item — drop it. Emit the group here only when it has no // (present) `placeAfter` anchor (default first-absorbed placement). - const usesPlaceAfter = group.placeAfter && presentNames.has(group.placeAfter); + const usesPlaceAfter = group.placeAfter && anchorExists(group.placeAfter); if (!usesPlaceAfter) emitGroup(group); continue; } result.push(entryFor(item)); - // Emit any groups anchored after this item. - for (const key of emitAfter.get(item.name) ?? []) { - const def = NAV_GROUPS.find((d) => d.key === key)!; - emitGroup(def); - } + emitAnchored(item.name); } // Safety net: append any active group not yet placed. diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json index 96182b3945..437b60657e 100644 --- a/web/src/locales/languages/en-US.json +++ b/web/src/locales/languages/en-US.json @@ -914,7 +914,8 @@ "keyboardShortcuts": "Keyboard Shortcuts", "synthetic": "Synthetics", "experience": "Experience", - "slos": "SLOs" + "slos": "SLOs", + "reliability": "Reliability" }, "rum": { "noSessionReplay": "No session replay available", @@ -5866,7 +5867,7 @@ "generalLabel": "General Settings", "groupGeneral": "General", "groupAccessSecurity": "Access & Security", - "groupDestinationsTemplates": "Destinations & Templates", + "groupDestinations": "Destinations", "groupDataAI": "Data & AI", "groupOperations": "Operations", "groupSynthetics": "Synthetics",