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:
Prabhat Sharma 2026-08-01 22:44:01 -07:00 committed by GitHub
parent 72f7bff378
commit d99ecff3e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 574 additions and 277 deletions

View File

@ -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))

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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(() => {});
}
/**

View File

@ -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');

View File

@ -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(() => {});
}

View File

@ -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' },
};
/**

View File

@ -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 });

View File

@ -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();

View File

@ -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 ===');

View File

@ -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');
}
},
{

View File

@ -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", () => {

View File

@ -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"),

View File

@ -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");
});
});

View File

@ -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).

View File

@ -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,
);
});
});
});

View File

@ -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 }),
},
],
},

View File

@ -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();

View File

@ -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"]);
});
});
});

View File

@ -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);

View File

@ -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)", () => {

View File

@ -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",

View File

@ -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.

View File

@ -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",