feat(nav): add Reliability menu, restore deferred SLO routes (#13577)
Groups the alerting surface under one left-rail tile, and un-defers the SLO backend that shipped disabled. > **Lands with** openobserve/o2-enterprise#2321, which adds `ROUTE_PERMISSIONS` for the restored SLO endpoints — the route-coverage test pairs the two, so this one alone fails it. ## Reliability menu New `reliability` nav group absorbing **Alerts, SLOs, Incidents**, plus **Notification Destinations** and **Templates**, which move out of Settings — they are alerting configuration, not deployment configuration. Settings keeps Pipeline Destinations (that one really is pipeline config); its group is renamed "Destinations". Their routes are **top-level and flat** (`/alert-destinations`, `/alert-templates`) rather than nested under `/alerts`. They are siblings of Alerts, not sub-pages of it — nesting them made the URL claim otherwise and the rail believed it, highlighting Alerts alongside them. - Route **names** are unchanged, so the ~15 call sites that navigate by name are untouched. - Old `/settings/*` paths redirect, with the query preserved — `?action=import` deep links keep working. ## SLO routes `/api/{org}/slos` answered **404**. The routes were deliberately unregistered before the last release (`6977b4020d`, `ac78a622e1`) while the rest of the feature — `core/src/slo/*`, the infra tables, both migrations, the `slo_maintenance` job, all seven handlers — stayed in the tree. This restores the five registrations and the OpenAPI entries, recovered from the pre-strip commit rather than rewritten. `ZO_SLO_ENABLED` still **defaults to false**. It is now published as `slo_enabled` on `/config`, so the menu entry follows the flag instead of offering a page the API answers with 501. ## Nav active-state Active state was decided per child, so any section whose path prefixed the current route lit up alongside it. It is now resolved once per flyout: exact route-name match wins, else the **deepest** path prefix — so a genuine drill-down like `/alerts/detail/:id` is still attributed to Alerts. `placeAfter` also accepts a group key, letting Data anchor on the Reliability tile it follows rather than on an item that tile absorbs. ## Tests `tests/ui-testing` referenced `[data-test="alert-destinations-tab"]` / `alert-templates-tab` in six files — those data-tests were on the Settings rail items removed here, so the locators would have matched nothing and hung until timeout. Those paths now go through the existing `openNavFlyoutChild` helper, extended with the Reliability group. Two URL regexes that would have silently mismatched on the hyphen are fixed (one ended in `.catch(() => {})`, so it would have degraded to a silent 15s stall). ## Verification - 3597 unit tests pass; `vue-tsc` and eslint clean; enterprise build compiles. - `playwright test --list`: all 141 tests across 23 files load, no module errors. - Against a live server: `GET /slos` → 200, `POST /slos/move` → 422 (so the literal wins over the `{slo_id}` catch-all), `GET /slos/x` → handler-level `{"code":404,"message":"SLO not found"}`. - In the running app: `/alert-destinations`, `/alert-templates` and `/alerts` each highlight exactly one row; `/settings/alert_destinations?...&action=import` redirects with the query intact; rail order unchanged.
This commit is contained in:
parent
72f7bff378
commit
d99ecff3e8
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 ===');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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"),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<Array<{ identifier?: string; [key: string]: unknown }>>([
|
||||
{ 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();
|
||||
|
|
|
|||
|
|
@ -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: "<div />" } },
|
||||
{ path: "/alerts", name: "alertList", component: { template: "<div />" } },
|
||||
{
|
||||
path: "/alert-destinations",
|
||||
name: "alertDestinations",
|
||||
component: { template: "<div />" },
|
||||
},
|
||||
{
|
||||
path: "/alert-templates",
|
||||
name: "alertTemplates",
|
||||
component: { template: "<div />" },
|
||||
},
|
||||
// A real drill-down under Alerts — this one SHOULD mark Alerts.
|
||||
{
|
||||
path: "/alerts/detail/:id",
|
||||
name: "alertDetail",
|
||||
component: { template: "<div />" },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<SubnavChild | null>(() => {
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
|
|
@ -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<RailEntry, { type: "linkGroup" }> =>
|
||||
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<RailEntry, { type: "linkGroup" }> =>
|
||||
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<RailEntry, { type: "linkGroup" }> =>
|
||||
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<RailEntry, { type: "linkGroup" }> =>
|
||||
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<RailEntry, { type: "linkGroup" }> =>
|
||||
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<RailEntry, { type: "linkGroup" }> =>
|
||||
e.type === "linkGroup" && e.item.name === "experience",
|
||||
|
|
|
|||
|
|
@ -50,11 +50,11 @@ export const GATE_PREDICATES: Record<string, (c: NavGateContext) => 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<string, string[]>();
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue